Migrate from SendGrid to Postal
SendGrid and Postal send the same email, but the call has a different format: in SendGrid, recipients, variables and arguments live inside personalizations; in Postal, they are fields of the email itself. This guide compares field by field and says what has no equivalent.
- Changes in code: sending and webhooks
- Rewrite
- You can run both together
- Yes
- Estimated time, depending on what you use
- Hours to days
- Type of sending, plus marketing
- API and SMTP
Verify the domain on Postal
Add the domain in the dashboard and create the records it shows. Going by the names, Postal's records don't collide with SendGrid's, and both stay verified at the same time during the switch. If you already have a _dmarc, keep yours.
- Postal checks the records on its own for up to 72 hours and notifies you by webhook (
domain.updated) - Create an API key (it starts with
ps_). For systems that only send, use a sending-only key, restricted to the domain
| Record | SendGrid (automated security) | Postal |
|---|---|---|
| DKIM | CNAME at s1._domainkey and s2._domainkey | TXT at postal._domainkey |
| Return-Path and SPF | CNAME at em and a number (for example, em1234) | MX and TXT at postal |
| DMARC | TXT at _dmarc | Optional. The same _dmarc works for both |
Rewrite the send call
Postal does not accept the /v3/mail/send format: the call becomes POST /emails, with the fields at the top level. Below, the same email in both formats. If you use the SendGrid SDK, replace the sending with the Postal SDK (Node.js, PHP and Laravel) or with HTTP, which is JSON with the key in the Authorization header.
- SendGrid responds 202 Accepted; Postal responds 200 with the email's
id. Keep theid: it is what shows up in webhooks and inGET /emails/{id} - Several recipients in the same
toform a single email in which everyone can see each other. For one email per person, use batch sending - Repeating the call with the same
Idempotency-Keydoes not duplicate the send
Before: SendGrid
POST https://api.sendgrid.com/v3/mail/send
Authorization: Bearer <SendGrid key>
{
"personalizations": [{
"to": [{ "email": "[email protected]", "name": "Ana" }],
"custom_args": { "order_id": "10482" }
}],
"from": { "email": "[email protected]", "name": "Loja Exemplo" },
"subject": "Your order #10482 is out for delivery",
"content": [
{ "type": "text/plain", "value": "Order #10482 is on its way." },
{ "type": "text/html", "value": "<p>Order <b>#10482</b> is on its way.</p>" }
],
"categories": ["shipping"]
}After: Postal
POST https://api.postal.so/emails
Authorization: Bearer ps_your_key
Idempotency-Key: order-10482-shipped
{
"from": "Loja Exemplo <[email protected]>",
"to": ["Ana <[email protected]>"],
"subject": "Your order #10482 is out for delivery",
"text": "Order #10482 is on its way.",
"html": "<p>Order <b>#10482</b> is on its way.</p>",
"tags": [
{ "name": "category", "value": "shipping" },
{ "name": "order_id", "value": "10482" }
]
}In code
- const sgMail = require('@sendgrid/mail');- sgMail.setApiKey(process.env.SENDGRID_API_KEY);- await sgMail.send({- to: '[email protected]',- from: '[email protected]',- subject, html,- });+ import { Postal } from 'postal-so';+ const postal = new Postal(); // reads POSTAL_API_KEY+ await postal.emails.send({+ from: 'Loja Exemplo <[email protected]>',+ to: '[email protected]',+ subject, html,+ });Map the fields
The table applies to new code and works as a checklist of what your system uses today. The rows that change how you build the email are the ones for batch recipients, categories and custom arguments.
| In SendGrid | On Postal | Note |
|---|---|---|
POST /v3/mail/send | POST /emails | The key goes in Authorization: Bearer on both. In Postal it starts with ps_. |
personalizations[].to, list of {email, name} | to: text or a list of up to 50, in the format Nome <email> | A list is a single email, in which everyone can see each other. |
Several personalizations (one email per item) | POST /emails/batch: list of up to 100 emails, each with its own to | In SendGrid the limit is 1,000 personalizations per request. Batch is a plan feature and is validated beforehand: either all are queued, or none. |
cc and bcc | cc and bcc | Same behavior: bcc is not shown to the others. |
from: {email, name} | from: text Nome <email> | The domain must be verified (and be the key's domain, if the key is restricted). |
reply_to and reply_to_list | reply_to: text or list | |
subject, global or per personalization | subject | A different subject per person: one item per email in the batch, or a template with a variable in the subject. |
content: list of {type, value} | html and text | Separate fields. Without text, html is required; send both when you can. |
attachments: content, filename, type, disposition, content_id | attachments: content, filename, content_type, content_id, and path (URL) | type becomes content_type. Inline image: content_id and cid: in the HTML. There is no disposition. Attachments up to 40 MB in total, not valid in batches and dependent on the plan. |
template_id and dynamic_template_data | template: {id, variables} | See “What has no equivalent”: Postal's templates only replace variables. |
headers | headers | Each platform has its own list of headers that can't be overridden. In Postal: From, To, Cc, Bcc, Subject, Reply-To, Sender, Date, Message-ID, Return-Path, MIME-Version, Content-Type, Content-Transfer-Encoding and DKIM-Signature. |
categories (up to 10) | tags: [{name, value}] (up to 50) | Name and value accept only ASCII letters, numbers, _ and - (up to 256 characters): “Promoção de Natal” becomes promocao-de-natal. |
custom_args | tags | Same character rule. In webhooks, tags come in data.tags ({"order_id": "10482"}), and not as loose keys on the event. For free-form data, store it in your database alongside the email's id. |
send_at, Unix, up to 72 hours | scheduled_at, ISO 8601, up to 30 days | Example: 2026-10-25T09:00:00-03:00. Not valid in batches and dependent on the plan. You can reschedule (PATCH /emails/{id}) and cancel (POST /emails/{id}/cancel). |
mail_settings.sandbox_mode | Test addresses: [email protected] and others | Instead of a request mode, you change the recipient. The email is simulated, uses no quota and generates real webhooks. |
tracking_settings.open_tracking and click_tracking | open_tracking and click_tracking on the domain | Turned on per domain (dashboard or PATCH /domains/{id}), not per email. Dependent on the plan. |
If you use SMTP, change the connection
If you send over SMTP, you rewrite nothing: you change the server, the user and the password. The fixed user is postal, not apikey. Postal also accepts 2465 (TLS) and 2587 (STARTTLS) for networks that block the usual ports.
- The
X-SMTPAPIheader (category,unique_args,sub,send_at,filters…) is not interpreted by Postal: it goes through as an ordinary header. Whatever you use it for has to move to the API - Recipients are the envelope ones (
RCPT TO), up to 50 per message. Anyone who doesn't appear in To or Cc is treated as Bcc - Postal's SMTP has no tags, scheduling or templates. Quotas, suppression list, events and webhooks are the same as the API's
- Messages up to 40 MB; Postal replies
250 2.0.0 OK queued as <id>, and the id is the email's id in the API
- Server: smtp.sendgrid.net- Port: 587 (or 25, 2525, 465)- User: apikey- Password: the SendGrid API key+ Server: smtp.postal.so+ Port: 465 (direct TLS) or 587 (STARTTLS)+ User: postal+ Password: the Postal API key (ps_…)Recreate the webhooks
SendGrid sends the Event Webhook signed with a public key (ECDSA); Postal signs with one secret per endpoint, following the Standard Webhooks standard. The body changes too: one event per request, with the email inside data.
| SendGrid event | Postal event | Note |
|---|---|---|
processed | email.sent | Approximate: Postal notifies you when it handed the email to the sending provider. |
delivered | email.delivered | The recipient's server accepted the message. |
deferred | email.delivery_delayed | Delivery was delayed and new attempts happen automatically. |
bounce | email.bounced | data.bounce carries type (Permanent, Transient or Undetermined) and message. A permanent bounce puts the address on the suppression list. |
dropped | email.suppressed or email.failed | Approximate: suppressed when the recipient was on the suppression list; failed, with failed.reason, when sending was not possible. |
open | email.opened | Only with open tracking turned on for the domain. |
click | email.clicked | data.click carries link, ipAddress, userAgent and timestamp. Only with click tracking turned on. |
spamreport | email.complained | The address goes on the suppression list. |
unsubscribe | contact.updated | A marketing contact's unsubscribe generates contact.updated. |
group_unsubscribe and group_resubscribe | no event of its own | Postal topics don't have an event with that name. |
| In SendGrid | On Postal | Note |
|---|---|---|
| One POST with the list of events | One POST per event: {type, created_at, data} | SendGrid's examples show an array of events. If your code loops over a list, it will now receive an object. |
sg_message_id, email, timestamp | data.email_id, data.to, created_at | The email_id is the id returned by POST /emails. The time comes in ISO 8601, not Unix. |
ECDSA signature: X-Twilio-Email-Event-Webhook-Signature and -Timestamp | Standard Webhooks, HMAC-SHA256: webhook-id, webhook-timestamp and webhook-signature | Replace the verification with the SDK's verifyWebhook or with the example in the documentation. The secret (whsec_…) belongs to each endpoint. Postal's webhook documentation does not include SendGrid's optional OAuth 2.0. |
| Retries for up to 24 hours, 2xx response | 8 attempts over about 27 hours, 2xx response within 15 seconds | All with the same webhook-id: use it to discard duplicates. Manual resend from the dashboard or via POST /webhooks/{id}/events/{event_id}/replay. |
| Event Webhook under Settings, Mail Settings | Webhooks, in the dashboard, or POST /webhooks | The URL must be HTTPS and public. You choose the events for each endpoint. |
import express from 'express';
import { verifyWebhook, PostalWebhookVerificationError } from 'postal-so';
const app = express();
// The body must arrive raw (Buffer) so the signature matches.
app.post('/webhooks/postal', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = verifyWebhook(req.body, req.headers, process.env.POSTAL_WEBHOOK_SECRET);
if (event.type === 'email.bounced') {
// remove the address
}
res.sendStatus(200);
} catch (error) {
if (error instanceof PostalWebhookVerificationError) {
return res.sendStatus(400);
}
throw error;
}
});Bring over suppressions and contacts
The suppression list first, so you don't write to people who shouldn't receive anything. Then the contacts, if you use the marketing audience. SendGrid exports each type of suppression through its own route; Postal has a single list.
- Do the contact import after the suppression: addresses already on the suppression list are ignored and don't count toward the plan's contact limit
- Marketing broadcasts and automations need to be recreated in Postal; what you bring over is the contacts and the content
curl -X POST https://api.postal.so/suppressions/batch/add \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"emails": ["[email protected]", "[email protected]"]}'| In SendGrid | On Postal | Note |
|---|---|---|
Bounces: GET /v3/suppression/bounces, up to 500 per page (limit, offset) | POST /suppressions/batch/add | Read the email of each item and send 100 per request. Addresses that bounce again in Postal are added automatically. |
Spam complaints: GET /v3/suppression/spam_reports | POST /suppressions/batch/add | People who complained should not receive from you again: bring them over too. |
Blocks: GET /v3/suppression/blocks | Don't import | In SendGrid, a block comes from a problem with the message or the sending IP and, by default, does not suppress future sends. Postal has no equivalent list. |
Global unsubscribes: GET /v3/suppression/unsubscribes | Contacts with unsubscribed: true (unsubscribe column in the CSV) | People who left only marketing stay in the audience without receiving broadcasts. If the person shouldn't receive anything, not even system emails, put the address on the suppression list: it does not become a contact. |
| Unsubscribe groups (ASM) | Topics, with each contact's choice (opt_in or opt_out) | CSV import can subscribe all imported contacts to topics. |
Contacts: PUT /v3/marketing/contacts, up to 30,000 per request | POST /contacts/batch, up to 10,000 | Above 1,000 items the batch goes to the queue (202) and you follow it at GET /contacts/batches/{id}. The batch creates or updates. |
Import CSV: PUT /v3/marketing/contacts/imports | Import CSV in the dashboard, or POST /contacts/imports | File up to 20 MB. Existing contacts are updated by email. |
Export: POST /v3/marketing/contacts/exports (CSV or JSON) | Export as CSV and import | SendGrid's export is asynchronous: download the files and use the CSV in the import. |
| Lists | Static segments | In the import, choose the segment. Only static segments receive imported contacts. |
| Custom fields | Contact properties | Create the properties before importing, because the import does not create new properties. |
Run both side by side and switch
With the domain verified on both, move one flow at a time. Invitations, password resets and codes should go out through a single path at a time.
- IDs belong to each platform: record which provider each email went out through and keep receiving events from SendGrid for what went out through it
- Use
tags(for example,provider: postal) to tell the new emails apart in webhooks - New accounts start with a ceiling of 500 emails a day, which grows with the account reputation, and sending pauses with permanent bounces above 3% to 5%, depending on the level, or complaints above 0.3%: raise the volume in stages
What has no equivalent
| In SendGrid | What to do in Postal |
|---|---|
Dynamic templates with Handlebars ({{#if}}, {{#each}}, dynamic_template_data with lists) | Postal's templates only replace {{{NOME}}} variables (text or number; in HTML, values are escaped). Resolve conditionals and lists in your system and send the finished HTML in html. |
ip_pool_name | There is no equivalent field in the send API. |
batch_id (cancel or pause a group of sends) | Cancel each scheduled email with POST /emails/{id}/cancel. |
asm.group_id on the send | Sending with POST /emails does not accept a topic. To request unsubscribing in system emails, use the List-Unsubscribe header in headers. |
footer, subscription_tracking and ganalytics per email | No equivalent in the send API: build the footer and the UTM links in the HTML. Postal's broadcast emails already carry the unsubscribe link. |
bypass_list_management and other bypass_* | There is no way to ignore the suppression list: suppressed recipients are removed from the send and generate email.suppressed. |
| Open and click tracking per email | Applies per domain, not per message. |
disposition of attachments | Doesn't exist. Inline images use content_id. |
X-SMTPAPI | Not interpreted. Use the API for tags, scheduling and variables. |
| Webhooks with OAuth 2.0 | Not in Postal's webhook documentation; protection is the signature. |
Before you flip the key
- Domain verified in Postal, API key created and stored in the environment
- Send call rewritten and tested with a test address
- Categories and custom arguments converted into tags (only ASCII letters, numbers, _ and -)
- Handlebars templates resolved in your system or recreated with simple variables
- Webhooks recreated, signature checked and code that read a list of events adjusted
- Suppressions and contacts imported, in that order
- Your code records which provider each email went out through
- Bounces checked in the first days after the switch
Frequently asked questions
Can I keep using @sendgrid/mail?
Not to send through Postal: Postal does not accept the /v3/mail/send format. Replace the sending with the Postal SDK or with an HTTP call to POST /emails.
Do I need to rewrite all my templates?
Only the ones that use Handlebars conditionals or loops. Templates that only swap names and numbers become templates with {{{VARIAVEL}}}. For sends by API, Postal only replaces variables.
My system sends over SMTP. What changes?
The server (smtp.postal.so), the user (postal) and the password (a Postal API key). If it uses the X-SMTPAPI header, those features need to move to the API.
Can I keep the domain authenticated in SendGrid?
Yes. The DNS records have different names, so both stay verified at the same time during the switch.
Keep going
Other guides
Each platform has its own: the general step-by-step is in Migrate.
Ready to rewrite the call?
Create your account and start on the Free plan. To plan your quota and the order of the migration, talk to us through the contact form.