Migrate from Mailgun
Migrating from Mailgun to Sendpository
Everything that changes when you move your email from Mailgun, in the order you'll do it. An afternoon - mostly swapping form-data for JSON.
In short
Migrating from Mailgun to Sendpository replaces form-encoded requests with JSON and basic auth with a Bearer key. The sending domain moves out of the URL and into the from address, o:tag and v: variables become a tags object, and webhook verification switches from HMAC over timestamp and token to HMAC over the timestamp and raw body.Or let your coding agent do the whole migration
Paste it in. It finds every Mailgun call, rewrites it, updates your webhook handler and switches over behind a flag you can flip back.
Show the promptHide the prompt
Switch this project's email from Mailgun to Sendpository. First run `npx sendpository@latest init` - it opens my browser for me to approve, saves the API key and installs the Sendpository skill - then follow the skill's migration guide (references/migrate.md). If you can't run commands, read https://www.sendpository.com/agents/sendpository/references/migrate.md instead.
1. API differences
| Mailgun | Sendpository | |
|---|---|---|
| Endpoint | POST https://api.mailgun.net/v3/<domain>/messages (EU: api.eu.mailgun.net) | POST https://api.sendpository.com/v1/emails - the domain comes from the from address |
| Encoding | multipart/form-data | application/json |
| Fields | from, to, subject, text, html | from, to (array), subject, text, html |
| Metadata | o:tag (repeatable) and v:<name> variables | tags: a string key/value object |
| Tracking | o:tracking-opens, o:tracking-clicks | track_opens, track_clicks |
| Scheduling | o:deliverytime (RFC 2822, up to 3 days ahead) | scheduled_at (ISO 8601) |
| Custom headers | h:<Header-Name> | headers: { "Header-Name": "value" } |
2. Authentication differences
| Mailgun | Sendpository | |
|---|---|---|
| API key | HTTP basic auth, user api and your API key as the password | Authorization: Bearer sp_... |
Create the key under API keys in the dashboard. It is shown once, so put it straight into SENDPOSITORY_API_KEY in your secrets manager. Keep the Mailgun key until the switch is finished, then revoke it.
3. Domain setup
Add your sending domain in Sendpository and publish the records it shows: a 2048-bit DKIM key signed as your own domain, plus SPF and a bounce MX on a sp. subdomain so your existing records are never edited. DMARC is recommended; if you already publish one, leave it as it is.
- Mailgun domains are often a subdomain like mg.yourdomain.com. Add whichever domain your From address uses - Sendpository signs as that domain.
- Mailgun's MX records only matter if you receive mail through Mailgun routes. Move receiving last, once sending is settled.
Not sure what’s published already? Run the domain through the email DNS checker.
4. Webhooks
| Mailgun | Sendpository | |
|---|---|---|
| Verification | signature object in the body: HMAC-SHA256 of timestamp + token with your webhook signing key | Sendpository-Signature header: HMAC-SHA256 of "timestamp.rawBody" with your endpoint secret |
Event names
| Mailgun event | Sendpository event |
|---|---|
| delivered | email.delivered |
| failed (temporary) | email.delayed |
| failed (permanent) | email.bounced |
| complained | email.complained |
| opened | email.opened |
| clicked | email.clicked |
| unsubscribed | email.unsubscribed |
Inbound routes become receiving addresses on your domain, delivered as email.received with an SPF, DKIM and DMARC verdict.
Full payloads and verification code are in the webhooks guide.
5. Error handling
| Mailgun | Sendpository | |
|---|---|---|
| Error shape | A JSON message describing the problem, with the HTTP status | { "error": { "type": "domain_not_verified", "message": "..." } } |
Sendpository returns { error: { type, message } } - branch on type. Only rate_limit_exceeded (wait for Retry-After) and internal_error are worth retrying automatically; the errors reference lists every type.
6. Code migration
const form = new FormData();
form.append("from", "Acme <billing@mg.yourdomain.com>");
form.append("to", "customer@example.com");
form.append("subject", "Your receipt");
form.append("html", "<p>Thanks for your order.</p>");
form.append("o:tag", "receipt");
await fetch("https://api.mailgun.net/v3/mg.yourdomain.com/messages", {
method: "POST",
headers: {
Authorization: "Basic " + btoa(`api:${process.env.MAILGUN_API_KEY}`),
},
body: form,
});const res = await fetch("https://api.sendpository.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDPOSITORY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "receipt-10428",
},
body: JSON.stringify({
from: "Acme <billing@mail.yourdomain.com>",
to: ["customer@example.com"],
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
text: "Thanks for your order.",
tags: { type: "receipt" },
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.type}: ${body.error.message}`);
console.log(body.id);Watch out for
- Batch sending with recipient-variables becomes one POST to /emails/batch with a message per recipient - personalise the HTML in your code.
- Stored templates (template with t:variables) have no equivalent; render the HTML before sending.
- The webhook signature moves from the body to a header and covers the raw body, so read the body as text before parsing it.
7. Pricing
| Mailgun | Sendpository | |
|---|---|---|
| Published pricing | Free: 100 emails a day, one sending domain. Basic starts at $15 a month for 10,000 emails. | 6,000 emails a month (up to 200 a day), free forever. Current plans |
Mailgun pricing from their own pages, checked 2026-09-23. Sendpository's free plan is larger: 6,000 emails a month (up to 200 a day), against Mailgun's 100 a day.
8. Things to test before switching
Specific to leaving Mailgun:
- Re-point anything that used Routes for inbound. Mailgun matches inbound mail with route expressions; ours delivers to an address on your domain and posts an `email.received` webhook, so the matching logic moves into your handler.
- Replace Events API polling. If a job walks Mailgun's events to reconcile state, our webhooks push the same transitions - keep the poller running against both until you trust the webhook path.
- Check for a region prefix. Mailgun's EU accounts use a different API host; there is no equivalent split here, so a hardcoded `api.eu.mailgun.net` has no counterpart to port.
True of any migration:
- Send to a real Gmail and a real Outlook address and open Show original: SPF, DKIM and DMARC should all say PASS, with DKIM signed as your own domain.
- Send a message with an attachment and one with an inline image, and check both render.
- Trigger a bounce (an address at a domain you control with no mailbox) and confirm your webhook handler receives email.bounced and marks the address.
- Send the same request twice with one Idempotency-Key and confirm only one email arrives.
- Point a staging environment at the new API key first, and watch the Logs page for 4xx responses before switching production.
- Keep the old provider's DNS records until the new domain has verified and a day of production mail has been delivered - removing them first breaks mail still in flight.
Questions
Does Sendpository replace Mailgun routes?
For receiving on your domain, yes: add receiving addresses or a catch-all, and each message arrives as an email.received webhook and through the API, with an SPF, DKIM and DMARC verdict. Forwarding rules and route expressions are handled in your own code.
Does Sendpository validate email addresses like Mailgun?
Not as a separate API. Hard bounces and complaints are suppressed automatically, so a bad address is only ever tried once.
Mailgun API details are from their documentation, checked 2026-09-25: documentation.mailgun.com, documentation.mailgun.com.
Keep reading
Sendpository vs Mailgun
Where each is the better choice, side by side.
Migrate from Resend
Usually under an hour for the code, plus DNS propagation.
Migrate from SendGrid
An afternoon for most codebases - the request shape is the main rewrite.
Migrate from Postmark
An hour or two - the request maps field for field.
Send your first email in minutes
6,000 emails a month (up to 200 a day), free forever. No card needed.