Migrate from SendGrid to Postal
Transactional email API · full migration guide
SendGrid → Postal is a code change plus a deliverability project. The code change is small: swap your `POST https://api.sendgrid.com/v3/mail/send` calls for `POST https://postal.your-domain.com/api/v1/send/message` with Postal's `X-Server-API-Key` header — the JSON body is similar, just renamed fields. The deliverability project is the actual work: SendGrid's IP reputation is shared and warmed; your Postal install is sending from a fresh box with no history. Most teams run Postal as the application gateway and chain it to Amazon SES or a similar relay so SES carries the IP reputation and Postal handles the routing, retries, suppression list, and analytics.
Prerequisites
- A self-hosted Postal instance — see /sendgrid/ for the 30-minute docker-compose recipe and ~$5-10/mo VPS sizing (Postal + MariaDB + RabbitMQ).
- DNS control on your sending domain — you'll add SPF, DKIM (Postal generates the keys), DMARC, and rDNS records.
- An upstream relay (recommended): Amazon SES at ~$0.10/1k emails. Configure SES first, verify your domain, request production access (the SES sandbox limits sends to verified addresses).
- Application access — you'll be modifying the email-sending code path. Have a code review / rollback plan.
- A test domain or non-critical email path to validate the cutover before touching production transactional mail.
Step 1 — Export from SendGrid
-
Inventory your SendGrid usage
SendGrid dashboard → Email API → Integration. Note: which API endpoints you call (`/v3/mail/send` is the common one; webhooks for events; subuser API for multi-tenant), volume per day, sender domains/identities, and any IP allowlist requirements your senders have.
-
Export the suppression list
SendGrid → Suppressions → Global Unsubscribes / Bounces / Spam Reports / Blocks / Invalid. Each list has a CSV export. Pull all four. This is the data you must preserve — re-sending to a previously-bounced or unsubscribed address is the fastest path to spam-folder hell.
-
Export dynamic templates
SendGrid → Email API → Dynamic Templates. There's no bulk export — for each template, click into it and copy the HTML and the test data. Save as `templates/<template-name>.html` in version control. Postal does not have a dynamic templates surface (it sends raw HTML), so templating moves to your application or a tool like Maizzle.
-
Document API key scopes
SendGrid → Settings → API Keys. List each key, its scope, and which application uses it. Postal's API keys are simpler — one key per 'mail server' (Postal's term for a sender configuration).
Step 2 — Import into Postal
-
Stand up Postal and configure DNS
Run the Postal docker-compose. Visit the Postal UI, create the first admin account. Settings → Organisation → New Organisation. New Mail Server → set sending domain. Postal generates a DKIM key — copy it to your DNS as a TXT record at `postal._domainkey.your-domain.com`. Add SPF (`v=spf1 include:postal.your-domain.com ~all` if Postal sends directly, or include your relay's SPF), DMARC (`v=DMARC1; p=none; rua=mailto:dmarc@your-domain.com`), and rDNS via your VPS provider.
-
Configure the upstream relay (recommended path)
In Postal: Mail Server → Settings → Outgoing → choose SMTP Relay. Enter SES SMTP credentials (`email-smtp.us-east-1.amazonaws.com`, port 587, STARTTLS). Test send. Postal now hands outbound mail to SES, which delivers from its warmed IPs.
-
Generate Postal API credentials
Postal → Mail Server → Credentials → New Credential → API Key. Save the key. Each credential has a scope — typically one per application or per environment.
-
Replace the SendGrid SDK with Postal's API
Code change. SendGrid's `POST /v3/mail/send` JSON body uses `personalizations[].to[].email`, `from.email`, `subject`, `content[].value`. Postal's `POST /api/v1/send/message` uses `to`, `from`, `subject`, `html_body`, `plain_body`, with the `X-Server-API-Key` header. Most languages' HTTP libraries can swap with a 20-line change. Some teams prefer to keep using SendGrid's SDK in their code and point it at a SendGrid-compatible proxy (Postal does not natively offer one; this is an Inbucket / MailSlurper pattern).
-
Import the suppression list
Postal → Mail Server → Suppression List → Import. Upload the SendGrid CSVs (you may need to merge into one column of email addresses — Postal accepts a simple line-per-email format). Verify the count matches before sending production mail.
-
Re-implement webhook event consumption
If your application listens to SendGrid's Event Webhook (delivered, bounced, opened, clicked, spamreport, dropped, etc.), Postal has equivalents: Mail Server → Webhooks → New Webhook. Set the URL of your event consumer. Postal's payload format differs — adjust your handler accordingly. Common mapping: SendGrid `bounce` → Postal `MessageBounced`, `delivered` → `MessageDelivered`, `open` → `MessageOpened`, `click` → `MessageClicked`.
-
Roll out gradually with a feature flag
Wrap your send code in a feature flag: 1% of sends through Postal, 99% through SendGrid. Watch the bounce + complaint metrics on both sides. Bump to 10%, 50%, 100% over a week. Postal → Messages shows real-time delivery status.
-
Cancel SendGrid
Once 100% of sends are through Postal for 2-4 weeks with healthy delivery metrics, cancel SendGrid (or downgrade to a free tier as a fallback). Save the suppression CSVs for 90 days as an audit trail.
Field / concept mapping
| SendGrid | Postal | Notes |
|---|---|---|
| SendGrid `POST /v3/mail/send` | Postal `POST /api/v1/send/message` | Different field names but similar structure. `personalizations[].to[].email` → `to[]`. `from.email` → `from`. `subject` → `subject`. `content[]` → `html_body` + `plain_body`. |
| SendGrid API key | Postal Server API Key (X-Server-API-Key header) | 1:1. One Postal key per sending application. |
| SendGrid Event Webhook | Postal Webhook | Event names differ: `delivered` → `MessageDelivered`, `bounce` → `MessageBounced`, etc. Adjust handler to the new payload shape. |
| SendGrid Dynamic Template | External templating (your app or Maizzle) | Postal does not have templates. Render the HTML in your application before calling Postal. |
| SendGrid Suppression List | Postal Suppression List | Bulk import via CSV. Postal honors suppressions globally per mail server. |
| SendGrid Subuser | Postal Mail Server (one per tenant) | Postal's organisation/mail-server model fits multi-tenant SaaS — give each tenant their own mail server with its own DKIM key for full isolation. |
| SendGrid IP Pool | Postal IP Pool | Postal supports multiple sending IPs per mail server. Useful if you have transactional and marketing on different IPs. |
| SendGrid Inbound Parse | Postal Incoming Routes | Both accept inbound email and POST to a webhook. Recreate the route in Postal's UI. |
| SendGrid stats / activity feed | Postal Messages dashboard | Postal's dashboard shows per-message status, opens, clicks, bounces. Comparable to SendGrid's Activity Feed. |
| SendGrid SMTP relay | Postal SMTP | Postal also accepts SMTP. If your stack uses SMTP today (most languages: just change `MAIL_HOST` env), the change is config-only. |
Downtime estimate
Zero downtime if you do the gradual feature-flag rollout (1% → 10% → 50% → 100% over a week or two). Hard cutover (single deploy switches all sends): 5-15 minutes of risk during the deploy as the new code path is exercised under real load — usually fine for low-volume teams, dangerous for high-volume transactional senders.
Common gotchas
- Cold IP reputation is the dominant risk. Sending 100k/day from day one of a fresh Postal install with a fresh VPS IP is the fastest path to spam-folder. The relay-through-SES path sidesteps this entirely.
- DMARC misconfig (e.g. `p=reject` before SPF/DKIM is verified passing) bounces real production mail. Start with `p=none`, watch reports for a week, then move to `p=quarantine` then `p=reject`.
- Postal stores message bodies in MariaDB by default — at high volume the DB grows quickly. Configure retention (Mail Server → Settings → Message Retention) to delete delivered messages after N days.
- RabbitMQ is in the Postal stack and is a real operational concern — if it crashes, queued mail piles up. Monitor it.
- SendGrid's `unsubscribe_groups` and `asm` (advanced suppression management) feature has no Postal equivalent. If you rely on per-list unsubscribe groups (newsletters vs transactional), pair Postal with Listmonk for the marketing layer.
Rollback plan
The feature-flag rollout makes rollback trivial — flip the flag back to SendGrid percentage. Keep your SendGrid account paid for the full migration window plus 30 days. The suppression list export from SendGrid is a one-way safety net — it ensures previously-unsubscribed users don't get re-mailed by your Postal instance, which would be a deliverability and legal issue. Hold the export CSVs in cold storage for at least 90 days.
Looking for setup time, monthly cost, and other alternatives? See Self-host SendGrid.