New: campaigns, automations and product emails in the same account. Meet Postal
Sign in Create account

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
1

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
The record names are different, so the two coexist on the same domain
RecordSendGrid (automated security)Postal
DKIMCNAME at s1._domainkey and s2._domainkeyTXT at postal._domainkey
Return-Path and SPFCNAME at em and a number (for example, em1234)MX and TXT at postal
DMARCTXT at _dmarcOptional. The same _dmarc works for both
2

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 the id: it is what shows up in webhooks and in GET /emails/{id}
  • Several recipients in the same to form 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-Key does not duplicate the send

Before: SendGrid

POST /v3/mail/send
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 /emails
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" }
  ]
}
200{ "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b" }

In code

Node.js: from @sendgrid/mail to the Postal SDK
- 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,+ });
3

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.

Mail Send: field by field
In SendGridOn PostalNote
POST /v3/mail/sendPOST /emailsThe 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 toIn 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 bcccc and bccSame 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_listreply_to: text or list
subject, global or per personalizationsubjectA 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 textSeparate fields. Without text, html is required; send both when you can.
attachments: content, filename, type, disposition, content_idattachments: 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_datatemplate: {id, variables}See “What has no equivalent”: Postal's templates only replace variables.
headersheadersEach 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_argstagsSame 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 hoursscheduled_at, ISO 8601, up to 30 daysExample: 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_modeTest addresses: [email protected] and othersInstead 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_trackingopen_tracking and click_tracking on the domainTurned on per domain (dashboard or PATCH /domains/{id}), not per email. Dependent on the plan.
4

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-SMTPAPI header (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
SMTP setup
- 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_…)
5

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.

Events: what maps to what
SendGrid eventPostal eventNote
processedemail.sentApproximate: Postal notifies you when it handed the email to the sending provider.
deliveredemail.deliveredThe recipient's server accepted the message.
deferredemail.delivery_delayedDelivery was delayed and new attempts happen automatically.
bounceemail.bounceddata.bounce carries type (Permanent, Transient or Undetermined) and message. A permanent bounce puts the address on the suppression list.
droppedemail.suppressed or email.failedApproximate: suppressed when the recipient was on the suppression list; failed, with failed.reason, when sending was not possible.
openemail.openedOnly with open tracking turned on for the domain.
clickemail.clickeddata.click carries link, ipAddress, userAgent and timestamp. Only with click tracking turned on.
spamreportemail.complainedThe address goes on the suppression list.
unsubscribecontact.updatedA marketing contact's unsubscribe generates contact.updated.
group_unsubscribe and group_resubscribeno event of its ownPostal topics don't have an event with that name.
Webhooks: format, signature and retries
In SendGridOn PostalNote
One POST with the list of eventsOne 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, timestampdata.email_id, data.to, created_atThe 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 -TimestampStandard Webhooks, HMAC-SHA256: webhook-id, webhook-timestamp and webhook-signatureReplace 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 response8 attempts over about 27 hours, 2xx response within 15 secondsAll 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 SettingsWebhooks, in the dashboard, or POST /webhooksThe URL must be HTTPS and public. You choose the events for each endpoint.
Node.js: verify a delivery
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;
  }
});
6

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
POST /suppressions/batch/add
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]"]}'
Suppressions and contacts
In SendGridOn PostalNote
Bounces: GET /v3/suppression/bounces, up to 500 per page (limit, offset)POST /suppressions/batch/addRead 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_reportsPOST /suppressions/batch/addPeople who complained should not receive from you again: bring them over too.
Blocks: GET /v3/suppression/blocksDon't importIn 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/unsubscribesContacts 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 requestPOST /contacts/batch, up to 10,000Above 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/importsImport CSV in the dashboard, or POST /contacts/importsFile up to 20 MB. Existing contacts are updated by email.
Export: POST /v3/marketing/contacts/exports (CSV or JSON)Export as CSV and importSendGrid's export is asynchronous: download the files and use the CSV in the import.
ListsStatic segmentsIn the import, choose the segment. Only static segments receive imported contacts.
Custom fieldsContact propertiesCreate the properties before importing, because the import does not create new properties.
7

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
Domain verified on bothOne flow on PostalCompare deliveries and bouncesMove the remaining flowsTurn off SendGrid

What has no equivalent

What has no equivalent, and how to work around it
In SendGridWhat 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_nameThere 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 sendSending 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 emailNo 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 emailApplies per domain, not per message.
disposition of attachmentsDoesn't exist. Inline images use content_id.
X-SMTPAPINot interpreted. Use the API for tags, scheduling and variables.
Webhooks with OAuth 2.0Not 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.

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.