Migrate from SendGrid to Unitpost
Medium migrationContacts, bounces, spam reports, global unsubscribes and dynamic templates import, but unsubscribe-group opt-outs and segments do not, so you copy those by hand before the first campaign.
What comes with you
To leave SendGrid, paste a SendGrid API key into Unitpost's Migration Assistant. It exports your Marketing contacts with custom fields and list membership, imports bounces, spam reports, blocks and global unsubscribes, and brings dynamic templates, authenticated domains and the event webhook. Unsubscribe-group opt-outs and segments do not import, so copy group opt-outs before any campaign. Send history, Single Sends, automations, subusers and IP pools stay at SendGrid.
| Data | Result | What happens |
|---|---|---|
| Contacts | Imports | The importer runs SendGrid's contact export (once for the account, once per list) and imports every Marketing contact with name and custom fields under their field names. Addresses you only emailed through Mail Send are not Marketing contacts unless you added them. |
| Suppressions (bounces and complaints) | Imports | Bounces and spam reports import with their reason. Blocks import too, as suppressions that stop every send, although SendGrid itself stops suppressing a blocked address. Invalid emails are not read. |
| Unsubscribes and consent | Partly imports | Global unsubscribes import as unsubscribe suppressions, and those contacts land unsubscribed, which stops marketing here. Unsubscribe-group opt-outs are not read; step 2 copies them. |
| Lists and segments | Partly imports | Marketing lists become static segments with their members. SendGrid segments are not read, even though the preview shows a notice about them; rebuild them as dynamic segments. |
| Topics | Partly imports | Each unsubscribe group becomes a topic that contacts are enrolled in by default, as groups work. Nobody's group opt-out comes with it. |
| Templates | Partly imports | Dynamic templates import the active version's subject, HTML and text as drafts in code mode. Plain {{name}} placeholders work; Handlebars helpers such as {{#if}} and {{#each}} do not, and legacy templates are not read. |
| Sending domains | Partly imports | Authenticated domains are recreated as verifying with a new DKIM key. Link branding is not read. |
| Webhooks | Partly imports | Only the webhook behind SendGrid's single-webhook settings endpoint is read, so accounts with several Event Webhooks lose the rest. It is recreated switched off with a new signing secret. |
| API keys | Rebuild | Keys cannot be read. Create Unitpost keys with the scopes each service needs. |
| Single Sends | Rebuild | Not read. Recreate scheduled ones as campaigns on a segment. |
| Subusers | Rebuild | The importer reads only the account its key belongs to. Unitpost has no subusers, so give each subuser its own workspace and run the importer with a key from that subuser. |
| Send history and analytics | Stays behind | Email Activity, stats and Single Send results stay in SendGrid. |
| Automations | Stays behind | Marketing automations stay in SendGrid; Unitpost automations are not launched yet. |
| IP pools and dedicated IPs | Stays behind | IP pools, dedicated IPs and their warmup history stay at SendGrid; nothing imports them. |
Before you start
- A SendGrid API key with Full Access, or a Restricted Access key with the read scopes listed under the key field in Settings → Migration. A narrower key skips whatever it cannot read.
- A Unitpost API key with
emails:sendfor your app, created in API keys. - A second Unitpost key for the migration steps, with
templates:write,topics:read,topics:writeandwebhooks:manage, saved asUNITPOST_MIGRATION_KEY. Keep it out of your app and revoke it once the move is done. - Access to your DNS host.
- If you use subusers, a decision on which workspace each one becomes.
The cut-over plan
SendGrid keeps sending until the last step, so there is no gap in delivery.
Run the Migration Assistant
Paste the key in Settings → Migration. The snapshot waits on SendGrid's contact export jobs, so a large account takes longer. In the preview's suppression list, SendGrid blocks show as
other: exclude the ones you still want to mail before you accept. Suppressions are written before contacts, so globally unsubscribed contacts land unsubscribed.Copy unsubscribe-group opt-outs
Do this before any campaign scoped to a topic. Read every group unsubscribe from
GET /v3/asm/suppressionsand opt those addresses out of the matching topic withPOST /api/v1/email/topics/{id}/opt-out. Each address must already be a contact here: the call refuses the whole list at the first one that is not.copy-group-unsubscribes.mjsimport { Unitpost } from "unitpost"; const unitpost = new Unitpost(process.env.UNITPOST_MIGRATION_KEY); // 1. Every group unsubscribe: one row per address per group. const rows = await fetch("https://api.sendgrid.com/v3/asm/suppressions", { headers: { Authorization: "Bearer " + process.env.SENDGRID_API_KEY }, }).then((r) => r.json()); // 2. The importer named each topic after its unsubscribe group. for await (const topic of unitpost.email.topics.listAll()) { const emails = rows .filter((r) => r.group_name === topic.name) .map((r) => r.email.toLowerCase()); for (let i = 0; i < emails.length; i += 1000) { const { error } = await unitpost.email.topics.optOut(topic.id, { emails: emails.slice(i, i + 1000), }); if (error) throw new Error(topic.name + ": " + error.message); } }Verify your domains next to SendGrid
Open Domains and publish the records shown: MX and SPF on
unitpost.<your domain>and a DKIM TXT under_domainkey. SendGrid'semands1/s2._domainkeyCNAMEs stay where they are, so both providers verify at once. Keep your single_dmarcrecord.Port dynamic templates
Unitpost fills
{{name}}placeholders only, with no loops or conditionals, so render{{#each}}tables in your code or split the template.first_name,last_name,emailandunsubscribe_urlare reserved and fill per recipient, so rename variables that use them and replace{{{unsubscribe}}}with{{unsubscribe_url}}. Imports land as marketing drafts, which adds a footer: set receipts and resets totransactionaland publish withPATCH /api/v1/email/templates/{id}.Switch the SDK
The display name moves into
from(Acme <hi@acme.com>). A template send passestemplate: { id, variables }with Unitpost'stmpl_id instead oftemplate_idanddynamic_template_data, andscheduled_at(ISO 8601, up to 90 days ahead) replaces the Unixsend_at, which SendGrid caps at 72 hours. Sends also accept anIdempotency-Key, which SendGrid does not offer.send.ts// Before: SendGrid const sgMail = require("@sendgrid/mail"); sgMail.setApiKey(process.env.SENDGRID_API_KEY); await sgMail.send({ to: "user@example.com", from: "hi@acme.com", subject: "Your receipt", html: receiptHtml, }); // After: Unitpost import { Unitpost } from "unitpost"; const unitpost = new Unitpost(process.env.UNITPOST_API_KEY); const { data, error } = await unitpost.email.send({ to: "user@example.com", from: "Acme <hi@acme.com>", subject: "Your receipt", html: receiptHtml, });Replace the webhook verifier
SendGrid's Signed Event Webhook is checked against a public key; Unitpost signs each delivery with a per-endpoint
whsec_secret in the svix format, one event per request with atypefield. Rotate the secret in Webhooks to see it, deploy the new verifier, then setstatustoenabled. A switched-off endpoint does not queue events.Send tests
Send each template once with
POST /api/v1/email/templates/{id}/test-send, and firePOST /api/v1/webhooks/{id}/testat the endpoint.Move traffic in stages
Move one message type first and watch
email.bouncedandemail.complainedfor it, then the rest. SendGrid stays verified, so rolling back is a config change.Cancel SendGrid last
Re-run step 2 on cut-over day, finish the checks below, then downgrade or close the account.
Name changes
Same ideas, new names
| SendGrid | Unitpost | Note |
|---|---|---|
| Marketing list | Segment (static) | |
| Segment | Segment (dynamic) | Rebuilt by hand. |
| Unsubscribe group | Topic | Imported with default_opt_in: true. |
| Global unsubscribe | Unsubscribed contact | Stops marketing; transactional still sends. |
| Dynamic template | Template | |
| Single Send | Campaign | |
| Custom field | Contact field | |
| Domain authentication | Domain | |
| Subuser | Separate workspace | |
| Category | Tag (tags: [{ name, value }]) |
API calls
| SendGrid API | Unitpost API | Note |
|---|---|---|
| POST /v3/mail/send | POST /api/v1/email | |
personalizations (up to 1,000 per call) | POST /api/v1/email/batch | Up to 100 messages per call, each personalized. |
template_id + dynamic_template_data | template: { id, variables } | |
send_at (Unix time, up to 72 hours ahead) | scheduled_at (ISO 8601 with a zone, up to 90 days ahead) | |
| PUT /v3/marketing/contacts | POST /api/v1/contacts/imports | Up to 30,000 contacts there, 5,000 rows here. |
| GET /v3/suppression/bounces | GET /api/v1/suppressions?reason=bounce | |
| GET /v3/suppression/spam_reports | GET /api/v1/suppressions?reason=complaint | |
| POST /v3/asm/groups/{group_id}/suppressions | POST /api/v1/email/topics/{id}/opt-out | |
| GET /v3/templates/{template_id} | GET /api/v1/email/templates/{id} | |
| GET /v3/user/webhooks/event/settings/all | GET /api/v1/webhooks |
SMTP settings
| SendGrid SMTP | Unitpost SMTP | Note |
|---|---|---|
| smtp.sendgrid.net | smtp.unitpost.com | |
Username apikey | Username unitpost | |
| Password: API key with Mail permission | Password: Unitpost API key with emails:send | |
| 25, 587, 2525 (TLS optional) | 587, 2587, 25 (STARTTLS) | TLS 1.2 or later is required before login. |
| 465 (SSL) | 465, 2465 (implicit TLS) |
Webhook events
| SendGrid event | Unitpost event | Note |
|---|---|---|
| processed | email.sent | |
| delivered | email.delivered | |
| deferred | email.delivery_delayed | |
| bounce | email.bounced | |
| dropped | email.failed | A send our suppression list stops is email.suppressed. |
| spamreport | email.complained | |
| open | email.opened | |
| click | email.clicked | |
| unsubscribe | contact.updated | |
| group_unsubscribe | contact.updated | The importer maps it here; topic.unsubscribed is the closer event. |
| group_resubscribe | contact.updated | topic.subscribed is the closer event. |
Before you cancel SendGrid
- Export contacts and suppression lists as files before you downgrade or close: when SendGrid retired its free plans, accounts left on them with more than 100 contacts had their contacts deleted.
- Download the Email Activity you need; SendGrid keeps up to 30 days of it only with its Email Activity history add-on, and none of it moves.
- Re-run the group unsubscribe copy (step 2) so late opt-outs reach Unitpost.
- Delete SendGrid's
emCNAME ands1._domainkeyands2._domainkeyCNAMEs once no mail goes through SendGrid. Keep_dmarcand Unitpost's records. - Delete SendGrid API keys and switch off its Event Webhooks.
Facts about SendGrid checked 2026-11-02. Providers change their APIs and plans; check their docs before you rely on a detail.
Questions about leaving SendGrid
Does SendGrid still have a free plan?
Do my unsubscribe groups and their opt-outs carry over?
Will my dynamic template Handlebars still render?
Can SendGrid keep sending while my domain verifies on Unitpost?
What does SendGrid still do that Unitpost does not?
Leaving SendGrid?
Preview the migration on the free tier
Verify a domain, paste a read-only key, and see what would come across before anything is written.