Guides
Migrating from SendGrid
Move an app from the SendGrid v3 Mail Send API to Refresh — field mapping, Event Webhook mapping, dual-run, then cut over.
This guide applies to any app that holds a SENDGRID_API_KEY. The Refresh API is not SendGrid-compatible on the wire. The concepts map one to one, and the app change is small: one request body, one webhook handler. Read Emails for every field.
Prerequisites (per domain)
- The domain exists in Refresh under the correct organization and has status Verified. See Verify a domain.
- An admin has registered the domain for sending on the domain page.
- An API key for the app with SEND permission, restricted to that domain (copy it once).
- Optional: a webhook endpoint in the app for
email.bounced/email.complained(andemail.unsubscribedif the app keeps its own list). - Optional: the templates of the app, published in Templates with the same variables.
Field mapping
Each row is true of the Refresh API today.
SendGrid v3 mail/send |
Refresh POST /emails |
|---|---|
personalizations[].to / cc / bcc |
to / cc / bcc (string or array). Bulk kinds need exactly one recipient per message. |
personalizations[].dynamic_template_data |
template.data. |
personalizations[].send_at / top-level send_at (unix seconds) |
scheduled_at (ISO 8601 with offset). Cancel with POST /emails/:id/cancel while the message is queued. |
from.email / from.name |
from as "Name <addr>". |
reply_to.email |
reply_to (one address). |
subject |
subject. |
content[] (text/plain, text/html) |
text and html. |
attachments[] (content, filename, type) |
attachments[] (content, filename, content_type). Not in batch requests. |
headers |
headers. The platform drops List-Unsubscribe headers and sets its own on bulk kinds. Other names the platform sets answer 422 reserved_header. |
template_id + dynamic_template_data |
template: { "id" or "slug", "version", "data" }. Drafts never resolve. |
categories |
kind (one, the message class) and tags (up to 20 name/value pairs). |
custom_args |
tags. Webhook payloads return them. |
asm.group_id and suppression groups |
A bulk kind prefix (bulk., broadcast., digest., sequence.) adds one-click unsubscribe headers. Topics and subscriptions take the place of groups. The suppression list takes the place of the global unsubscribe list. |
mail_settings.sandbox_mode |
A Test key (f5_test_…). The message goes through the full contract and never leaves the platform. |
tracking_settings.open_tracking / click_tracking |
track: { "opens", "clicks" }. Off by default. Needs the tracking host of the domain and one recipient. |
batch_id (pause or cancel a scheduled batch) |
Not applicable. Cancel each scheduled message by id. |
ip_pool_name / dedicated IPs |
Not applicable. Each organization has isolated reputation controls; Refresh has no dedicated IP today. |
| Subusers | Organizations. Every domain, key, contact and message belongs to one organization. |
SMTP relay (apikey username, port 587) |
The SMTP relay: smtp.f5send.com, port 465 (implicit TLS). Username = the id of the API key (not the word apikey), password = the token. |
| Email Activity (3 or 7 days) | Event retention per plan: Free 30 days, Pro 90, Scale 90, Enterprise 365. An admin can change it in Settings → Organization. |
The SendGrid body is one request with many personalizations. The Refresh body is one message. For a bulk kind, send one message per recipient with POST /emails/batch (up to 100 per call). For a transactional message with several recipients, pass an array in to.
App change
Base URL:
https://api.f5send.com/api/v1(soPOST https://api.f5send.com/api/v1/emails).Header:
Authorization: Bearer f5_live_….Add
Idempotency-Keyon every cron or scheduled send, so a retried job can never send twice.Replace
@sendgrid/mailwithfetch. The response is{ "id" }; store the id, it is theemail_idin every webhook payload.Bulk messages (digests, sequences, broadcasts): pass
kind: "digest.weekly"(orbulk.*,broadcast.*,sequence.*) with one recipient per message. The platform then adds RFC 8058 one-click unsubscribe headers and enforces suppression.Transactional messages: any other
kind(invoice,alert.reminder) or none. No unsubscribe header.
// before
import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
await sgMail.send({
to,
from: { email: "billing@example.com", name: "Acme" },
subject,
html,
categories: ["invoice"],
customArgs: { invoice_id: invoiceId },
});
// after
await fetch("https://api.f5send.com/api/v1/emails", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.F5SEND_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `invoice:${invoiceId}` },
body: JSON.stringify({
from: "Acme <billing@example.com>",
to,
subject,
html,
kind: "invoice",
tags: [{ name: "invoice_id", value: invoiceId }],
}),
});
A dynamic template send maps the same way. template_id becomes template.slug (or template.id), and dynamic_template_data becomes template.data. The platform renders the template when it accepts the request and answers 422 template_variables for a missing or undeclared variable. See Templates.
Event Webhook
The SendGrid Event Webhook posts an array of events. Refresh posts one event per request, signed with f5send-signature. Create the endpoint in Webhooks and store the secret. Verify with verifyWebhookSignature on the raw body. See Webhook payloads.
| SendGrid event | Refresh type |
|---|---|
processed |
email.sent |
delivered |
email.delivered |
deferred |
email.delivery_delayed |
bounce |
email.bounced with bounce_type: "hard" |
blocked |
email.bounced with bounce_type: "soft", or email.rejected |
dropped |
No webhook. The suppressed array of the send response lists the addresses, and the message gets a suppressed event. |
spamreport |
email.complained |
open |
email.opened |
click |
email.clicked (with url) |
unsubscribe / group_unsubscribe |
email.unsubscribed |
group_resubscribe |
No webhook. Read the contact, or set the subscription with POST /contacts/:id/subscriptions. |
sg_event_id and sg_message_id have no equivalent. Use f5send-delivery-id (stable across retries) and data.email_id. category and unique_args arrive as data.kind and data.tags.
Suppressions
Export the SendGrid global unsubscribes, bounces and spam reports before you cut over. Add them with POST /suppressions (reason manual) or in the console. The worker then adds new hard bounces, complaints and unsubscribes by itself. See Suppressions.
Suppression groups become topics (Audience → Topics). A broadcast with kind: "broadcast.<slug>" or a topic tag names the topic. An unsubscribe from that message changes only that topic. A bulk message with no topic adds the address to the organization-wide suppression list.
Dual-run (1–2 weeks)
Refresh has no SendGrid transport, so the dual-run happens in your app. Move one kind at a time, transactional first, and keep the SendGrid account alive. If anything regresses, point that kind back at SendGrid while you investigate. Watch Metrics (delivery / bounce / complaint), Emails, and the DMARC tab of the domain (alignment of the new source).
Both senders can sign for the same domain at the same time. The Refresh DKIM selectors (ep1, ep2) do not collide with the SendGrid ones (s1, s2).
Cut over
- Remove the SendGrid DKIM (
s1._domainkey,s2._domainkey) and link-branding / return-path (em….<domain>,url….<domain>) records. The Refresh records stay:ep1/ep2,bounce.,_dmarc,link./reply.. - Delete the SendGrid Event Webhook. Revoke the SendGrid API key in the app.
Decommission checklist (per domain)
Preconditions: ≥ 14 days on Refresh with delivery ≥ 98 %, bounce < 2 % and complaint < 0.1 %. The DMARC tab shows the new source as aligned. No app still holds a SENDGRID_API_KEY for the domain.
- In Refresh: the domain sends, and the suppression list holds the SendGrid export.
- In the app: no
SENDGRID_API_KEYremains; only the Refresh key and base URL remain. - In DNS: the SendGrid records no longer exist (see above).
- In SendGrid: revoke all keys, delete the authenticated domain, delete the subuser.