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
| Mailgun | Sendbound | |
|---|---|---|
| API base | https://api.mailgun.net/v3/{domain} | https://api.sendbound.com/v1 |
| Auth | HTTP Basic (api:{key}) | Bearer token (sk_live_xxx) |
| Content type | multipart/form-data | application/json |
| Send endpoint | POST /{domain}/messages | POST /send |
| Template syntax | Handlebars {{variable}} | Handlebars {{variable}} ✓ Same |
| Recipient variables | recipient-variables JSON | Per-call variables object |
| Webhook signing | HMAC-SHA256 on timestamp + token | HMAC-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/latestExport 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 syntax | Sendbound equivalent | Action |
|---|---|---|
%recipient.first_name% | {{first_name}} | Replace %recipient.x% with {{x}} |
{{unsubscribe_url}} | {{unsubscribe_url}} | No change |
Custom headers h:X-* | Not supported | Remove or use reply-to field |
Webhook migration
Event name mapping
| Mailgun event | Sendbound event |
|---|---|
accepted | (not emitted) |
delivered | email.delivered |
opened | email.opened |
clicked | email.clicked |
failed (permanent) | email.bounced |
failed (temporary) | email.bounced (with reason) |
complained | email.complained |
unsubscribed | email.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
Migrate from SendGrid
Step-by-step guide to moving from Twilio SendGrid to Sendbound. API mapping, template porting, webhook translation, and parallel-run strategy.
Migrate from Postmark
Step-by-step guide to moving from Postmark to Sendbound. API mapping, message stream mapping, template porting, and webhook translation.

