RefreshDocsConsole →

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)

  1. The domain exists in Refresh under the correct organization and has status Verified. See Verify a domain.
  2. An admin has registered the domain for sending on the domain page.
  3. An API key for the app with SEND permission, restricted to that domain (copy it once).
  4. Optional: a webhook endpoint in the app for email.bounced / email.complained (and email.unsubscribed if the app keeps its own list).
  5. 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 (so POST https://api.f5send.com/api/v1/emails).

  • Header: Authorization: Bearer f5_live_….

  • Add Idempotency-Key on every cron or scheduled send, so a retried job can never send twice.

  • Replace @sendgrid/mail with fetch. The response is { "id" }; store the id, it is the email_id in every webhook payload.

  • Bulk messages (digests, sequences, broadcasts): pass kind: "digest.weekly" (or bulk.*, 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.

  1. In Refresh: the domain sends, and the suppression list holds the SendGrid export.
  2. In the app: no SENDGRID_API_KEY remains; only the Refresh key and base URL remain.
  3. In DNS: the SendGrid records no longer exist (see above).
  4. In SendGrid: revoke all keys, delete the authenticated domain, delete the subuser.