Migrate from Postmark
Migrating from Postmark to Sendpository
Everything that changes when you move your email from Postmark, in the order you'll do it. An hour or two - the request maps field for field.
In short
Migrating from Postmark to Sendpository is a field rename and an auth change: PascalCase fields (From, To, HtmlBody, TextBody) become lowercase ones (from, to, html, text), and the X-Postmark-Server-Token header becomes a Bearer key. Sendpository also signs every webhook with HMAC-SHA256, where Postmark relies on basic auth or IP allowlisting.Or let your coding agent do the whole migration
Paste it in. It finds every Postmark 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 Postmark 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
| Postmark | Sendpository | |
|---|---|---|
| Endpoint | POST https://api.postmarkapp.com/email | POST https://api.sendpository.com/v1/emails |
| Fields | From, To (comma-separated string), Cc, Bcc, Subject, HtmlBody, TextBody, ReplyTo | from, to (array), cc, bcc, subject, html, text, reply_to |
| Metadata | Tag (one string) and Metadata (key/values) | tags: a string key/value object |
| Tracking | TrackOpens, TrackLinks | track_opens, track_clicks - or an account default |
| Streams | MessageStream separates transactional and broadcast | No streams - use tags to tell mail apart |
| Batch | POST /email/batch, up to 500 | POST https://api.sendpository.com/v1/emails/batch, up to 100 |
| Response | { To, SubmittedAt, MessageID, ErrorCode, Message } | { "id": "..." } on success; an error object otherwise |
2. Authentication differences
| Postmark | Sendpository | |
|---|---|---|
| API key | X-Postmark-Server-Token: <server token> | 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 Postmark 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.
- Postmark verifies a DKIM record and a Return-Path CNAME. Sendpository's DKIM uses a different selector, and its bounce record lives on a sp. subdomain, so both can coexist.
Not sure what’s published already? Run the domain through the email DNS checker.
4. Webhooks
| Postmark | Sendpository | |
|---|---|---|
| Verification | No HMAC signature - secured with basic auth in the URL or an IP allowlist | Every request signed: Sendpository-Signature: t=<unix>,v1=<HMAC-SHA256>. Verify it and you can drop the basic-auth credentials. |
Event names
| Postmark event | Sendpository event |
|---|---|
| Delivery | email.delivered |
| Bounce | email.bounced |
| Spam Complaint | email.complained |
| Open Tracking | email.opened |
| Click | email.clicked |
| Subscription Change | email.unsubscribed |
| Inbound | email.received |
Full payloads and verification code are in the webhooks guide.
5. Error handling
| Postmark | Sendpository | |
|---|---|---|
| Error shape | HTTP 422 with a numeric ErrorCode and a Message, e.g. 406 for an inactive recipient | { "error": { "type": "domain_not_verified", "message": "..." } } |
Swap numeric ErrorCode checks for error.type strings: an inactive recipient is suppressed_recipient, an unverified sender is domain_not_verified. Only rate_limit_exceeded (wait for Retry-After) and internal_error are worth retrying automatically; the errors reference lists every type.
6. Code migration
await fetch("https://api.postmarkapp.com/email", {
method: "POST",
headers: {
"X-Postmark-Server-Token": process.env.POSTMARK_SERVER_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
From: "Acme <billing@yourdomain.com>",
To: "customer@example.com",
Subject: "Your receipt",
HtmlBody: "<p>Thanks for your order.</p>",
TextBody: "Thanks for your order.",
Tag: "receipt",
MessageStream: "outbound",
}),
});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
- To becomes an array - split any comma-separated recipient string.
- A single Tag becomes a key in tags, e.g. { "type": "receipt" }.
- If you relied on ErrorCode 406 to skip inactive recipients, check for error.type === "suppressed_recipient" instead.
- Webhook handlers should verify the signature; the basic-auth credentials in the URL are no longer needed.
7. Pricing
| Postmark | Sendpository | |
|---|---|---|
| Published pricing | Free developer plan: 100 emails a month, no expiry. Basic is $15 a month for 10,000 emails, with overage at $1.80 per 1,000. | 6,000 emails a month (up to 200 a day), free forever. Current plans |
Postmark 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 Postmark's 100 a month.
8. Things to test before switching
Specific to leaving Postmark:
- Decide which Message Stream each send belonged to. Postmark separates transactional and broadcast streams with their own reputations; we have one sending reputation per domain, so anything you were sending on a broadcast stream needs its unsubscribe handling checked.
- Check anything that relied on 45-day full-content retention. Our logs keep the metadata and events for the full window but the body for a shorter one, so export anything you depend on before you cut over.
- Swap the `X-PM-Message-Id` header if you correlate on it. Ours returns an `id` in the response body instead.
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 have message streams like Postmark?
No. Tag messages instead - tags: { "stream": "broadcast" } - and filter by tag in the dashboard. Sendpository is built for transactional mail; bulk marketing belongs on a marketing platform either way.
Are Sendpository webhooks signed?
Yes. Every webhook carries a Sendpository-Signature header with an HMAC-SHA256 of the timestamp and raw body, so you can reject forged and replayed requests without relying on an IP allowlist.
Postmark API details are from their documentation, checked 2026-09-25: postmarkapp.com, postmarkapp.com.
Keep reading
Sendpository vs Postmark
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 Mailgun
An afternoon - mostly swapping form-data for JSON.
Send your first email in minutes
6,000 emails a month (up to 200 a day), free forever. No card needed.