SendboundSendbound

Migrate from Mailgun

Step-by-step guide to moving from Sinch Mailgun to Sendbound. API mapping, domain configuration, webhook translation, and parallel-run strategy.

This guide covers everything specific to migrating from Sinch Mailgun.

Before starting, read the general migration guide for the parallel-run strategy and DNS playbook.


What's different

MailgunSendbound
API basehttps://api.mailgun.net/v3/{domain}https://api.sendbound.com/v1
AuthHTTP Basic (api:{key})Bearer token (sk_live_xxx)
Content typemultipart/form-dataapplication/json
Send endpointPOST /{domain}/messagesPOST /send
Template syntaxHandlebars {{variable}}Handlebars {{variable}} ✓ Same
Recipient variablesrecipient-variables JSONPer-call variables object
Webhook signingHMAC-SHA256 on timestamp + tokenHMAC-SHA256 on raw payload

Export from Mailgun

Export templates

Sending → Templates → select each template → Copy HTML.

Mailgun has no bulk template export. If you have many templates, use the API:

curl -u "api:YOUR_MAILGUN_KEY" \
  https://api.mailgun.net/v3/yourdomain.com/templates \
  | jq -r '.items[].name'

Then fetch each:

curl -u "api:YOUR_MAILGUN_KEY" \
  https://api.mailgun.net/v3/yourdomain.com/templates/TEMPLATE_NAME/versions/latest

Export contacts

Mailing Lists → select list → download CSV. If you use Mailgun's contact database, export via the Contacts section.

Also export your Suppressions (bounces, unsubscribes, complaints) from Sending → Suppressions.

Note your webhook configuration

Sending → Webhooks → copy the URLs and event types configured.

Save your webhook signing key from the Webhooks page — you'll reference the verification logic when updating to Sendbound's format.


API migration

Mailgun uses form-encoded POST requests with HTTP Basic auth. Sendbound is JSON with Bearer auth.

Sending a transactional email

import FormData from 'form-data';
import Mailgun from 'mailgun.js';

const mg = new Mailgun(FormData);
const client = mg.client({ username: 'api', key: process.env.MAILGUN_API_KEY! });

await client.messages.create('mg.yourdomain.com', {
  from: 'Sender <hello@yourdomain.com>',
  to: ['user@example.com'],
  subject: 'Welcome',
  html: '<p>Welcome aboard!</p>',
});
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',
    fromName: 'Sender',
    subject: 'Welcome',
    html: '<p>Welcome aboard!</p>',
  }),
});

Using a stored template

await client.messages.create('mg.yourdomain.com', {
  from: 'hello@yourdomain.com',
  to: ['user@example.com'],
  subject: 'Welcome',
  template: 'welcome-email',
  'h:X-Mailgun-Variables': JSON.stringify({ first_name: 'Jane' }),
});
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_your_id',
    variables: { first_name: 'Jane' },
  }),
});

Template migration

Mailgun uses Handlebars ({{variable}}). Sendbound is compatible. However, watch for these Mailgun-specific patterns:

Mailgun syntaxSendbound equivalentAction
%recipient.first_name%{{first_name}}Replace %recipient.x% with {{x}}
{{unsubscribe_url}}{{unsubscribe_url}}No change
Custom headers h:X-*Not supportedRemove or use reply-to field

Webhook migration

Event name mapping

Mailgun eventSendbound event
accepted(not emitted)
deliveredemail.delivered
openedemail.opened
clickedemail.clicked
failed (permanent)email.bounced
failed (temporary)email.bounced (with reason)
complainedemail.complained
unsubscribedemail.unsubscribed

Signature verification

import crypto from 'crypto';

function verifyMailgun(signingKey: string, token: string, timestamp: string, signature: string) {
  const value = timestamp + token;
  const hash = crypto.createHmac('sha256', signingKey).update(value).digest('hex');
  return hash === signature;
}
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));
}

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

Domain notes

Mailgun requires a sending subdomain (e.g. mg.yourdomain.com). Sendbound sends from your root domain (yourdomain.com) — this is better for deliverability since the From address and DKIM signing domain match.

Remove the MX record Mailgun required on your subdomain after cutover. Keep the MX on your root domain for receiving.


Checklist

  • Exported all templates
  • Exported contacts + suppression lists
  • Added and verified root domain in Sendbound (not the mg. subdomain)
  • Imported suppression lists
  • Imported contacts
  • Updated API calls (form-data → JSON, Basic auth → Bearer)
  • Updated %recipient.x% syntax in templates to {{x}}
  • Configured Sendbound webhook
  • Updated webhook signature verification
  • Ran parallel test
  • Cut over
  • Removed Mailgun CNAME/MX records for the mg. subdomain
  • Revoked Mailgun API key