SendboundSendbound

Migrate from SendGrid

Step-by-step guide to moving from Twilio SendGrid to Sendbound. API mapping, template porting, webhook translation, and parallel-run strategy.

This guide covers everything specific to migrating from Twilio SendGrid — API differences, template syntax, event webhook mapping, and what to watch for.

Before starting, read the general migration guide for the parallel-run strategy and DNS playbook that applies to all providers.


What's different

SendGridSendbound
API basehttps://api.sendgrid.com/v3https://api.sendbound.com/v1
Auth headerAuthorization: Bearer SG.xxxAuthorization: Bearer sk_live_xxx
Send endpointPOST /mail/sendPOST /send
Template syntaxHandlebars {{variable}}Handlebars {{variable}} ✓ Same
Dynamic templatesSeparate template ID + dynamic_template_dataInline or stored template with variables
Webhook eventsMultiple event types per payload itemOne event per webhook call
Unsubscribe groupsBuilt-in group managementSuppression lists + segments

Export from SendGrid

Export templates

  1. Marketing → Email Designs → download each design as HTML
  2. Transactional → Dynamic Templates → open each template → download HTML version

Store the HTML files locally — you'll import them into the Sendbound template editor.

Export contacts

  1. Marketing → Contacts → All Contacts
  2. Click Export → wait for the CSV email
  3. Also export Unsubscribes and Bounces from Suppressions — import these first in Sendbound

Note your webhook configuration

  1. Settings → Mail Settings → Event Webhook
  2. Copy the URL you're currently posting to
  3. Note the event types you have enabled
  4. Copy the Signed Webhook Verification Key if you're verifying signatures

API migration

Sending a transactional email

import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

await sgMail.send({
  to: 'user@example.com',
  from: 'hello@yourdomain.com',
  subject: 'Welcome to the app',
  html: '<p>Welcome aboard!</p>',
});
// No SDK needed
await fetch('https://api.sendbound.com/v1/send', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SENDBOUND_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: 'user@example.com',
    from: 'hello@yourdomain.com',
    subject: 'Welcome to the app',
    html: '<p>Welcome aboard!</p>',
  }),
});

Using a stored template

await sgMail.send({
  to: 'user@example.com',
  from: 'hello@yourdomain.com',
  templateId: 'd-abc123',
  dynamicTemplateData: {
    first_name: 'Jane',
    confirm_url: 'https://app.example.com/confirm/xyz',
  },
});
await fetch('https://api.sendbound.com/v1/send', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SENDBOUND_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: 'user@example.com',
    from: 'hello@yourdomain.com',
    templateId: 'tmpl_abc123', // Sendbound template ID from template editor
    variables: {
      first_name: 'Jane',
      confirm_url: 'https://app.example.com/confirm/xyz',
    },
  }),
});

Sending to multiple recipients

await sgMail.sendMultiple({
  to: ['alice@example.com', 'bob@example.com'],
  from: 'hello@yourdomain.com',
  subject: 'Hello everyone',
  html: '<p>Hi team!</p>',
});
// Send individually for tracking per-recipient
await Promise.all(
  ['alice@example.com', 'bob@example.com'].map(to =>
    fetch('https://api.sendbound.com/v1/send', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.SENDBOUND_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ to, from: 'hello@yourdomain.com', subject: 'Hello everyone', html: '<p>Hi team!</p>' }),
    })
  )
);

Template migration

SendGrid dynamic templates use Handlebars ({{variable}}). Sendbound uses the same syntax — most templates work without modification.

What works unchanged

Hello {{first_name}},

{{#if trial_active}}
Your trial ends on {{trial_end_date}}.
{{else}}
Your subscription is active.
{{/if}}

What needs updating

SendGridSendboundNotes
{{{unsubscribe}}}{{unsubscribe_url}}Rename the variable
{{asm_group_unsubscribe_raw_url}}{{unsubscribe_url}}Sendbound uses one universal unsubscribe URL
{{sendgrid_unsubscribe_preferences_raw_url}}Remove or replace with your preference center URL

Webhook migration

Update your endpoint

In Sendbound: Developer → Webhooks → Add endpoint. Enter the same URL your SendGrid webhook was posting to.

Event name mapping

SendGrid eventSendbound event
processed(not emitted — delivery is the confirmation)
deliveredemail.delivered
openemail.opened
clickemail.clicked
bounceemail.bounced
droppedemail.bounced (with reason: dropped)
spamreportemail.complained
unsubscribeemail.unsubscribed
group_unsubscribeemail.unsubscribed

Signature verification

import { EventWebhook, EventWebhookHeader } from '@sendgrid/eventwebhook';

const ew = new EventWebhook();
const key = ew.convertPublicKeyToECDH(process.env.SENDGRID_WEBHOOK_KEY!);
const valid = ew.verifySignature(
  key,
  payload,
  req.headers[EventWebhookHeader.SIGNATURE] as string,
  req.headers[EventWebhookHeader.TIMESTAMP] as string,
);
import crypto from 'crypto';

function verifyWebhook(payload: string, signature: string, secret: string) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected),
  );
}

// In your handler:
const isValid = verifyWebhook(
  rawBody,
  req.headers['x-sendbound-signature'] as string,
  process.env.SENDBOUND_WEBHOOK_SECRET!,
);

SPF record update

# Before (SendGrid only)
v=spf1 include:sendgrid.net ~all

# During parallel run (keep both)
v=spf1 include:sendgrid.net include:sendbound.com ~all

# After cutover (remove SendGrid)
v=spf1 include:sendbound.com ~all

Checklist

  • Exported all templates from SendGrid
  • Exported contact list + suppression lists
  • Added and verified sending domain in Sendbound
  • Imported suppression list (bounces + unsubscribes)
  • Imported contacts
  • Imported templates and verified rendering
  • Updated API calls in staging
  • Updated SPF record to include both providers
  • Configured Sendbound webhook
  • Updated webhook signature verification code
  • Ran parallel test (both providers live)
  • Cut over to Sendbound in production
  • Removed SendGrid include: from SPF
  • Revoked SendGrid API key
  • Cancelled SendGrid plan