SendGrid compatibility

Apps built against SendGrid's v3 Mail Send API can send through the platform by changing the API host and the key. The platform accepts SendGrid's request on POST /v3/mail/send, answers as SendGrid does (202 with X-Message-Id, or {"errors": [...]}), and can send webhooks shaped like SendGrid's signed Event Webhook and its Inbound Parse. Everything else uses the native API (REST API, webhooks).

Apps built against SendGrid's v3 Mail Send API can send through the platform by changing the API host and the key. The platform accepts SendGrid's request on POST /v3/mail/send, answers as SendGrid does (202 with X-Message-Id, or {"errors": [...]}), and can send webhooks shaped like SendGrid's signed Event Webhook and its Inbound Parse. Everything else uses the native API (REST API, webhooks).

Moving an app over, step by step: moving from SendGrid.

Endpoint and authentication

Method Path Body
POST /v3/mail/send one SendGrid Mail Send request, up to 500 personalizations

The path sits at the root of the platform host (https://<host>/v3/mail/send), not under /api/v1. The app's API key goes where SendGrid's key went:

POST /v3/mail/send HTTP/1.1
Host: app.mailroomly.com
Content-Type: application/json
Authorization: Bearer mp_live_…

The key must have the SEND permission, and its domain and stream restrictions apply. A personal token (Bearer mp_pat_…) works too, sending as its person within the token's scopes and mailboxes, with no app and so no webhook. Nothing else is accepted: a request without a key or a token, including one carrying a signed-in browser's session cookie, is answered 401 before anything else runs.

SendGrid's libraries

Point the library at the platform's host and give it the app's key; the code that builds and sends the mail stays the same.

Library Setting
Node (@sendgrid/mail) client.setApiKey(key); client.setDefaultRequest('baseUrl', 'https://app.mailroomly.com'); sgMail.setClient(client), with client from @sendgrid/client
Python (sendgrid) SendGridAPIClient(api_key=key, host="https://app.mailroomly.com")
PHP (sendgrid/sendgrid) new \SendGrid($key, ['host' => 'https://app.mailroomly.com'])
Ruby (sendgrid-ruby) SendGrid::API.new(api_key: key, host: 'https://app.mailroomly.com')
Go (sendgrid-go) request := sendgrid.GetRequest(key, "/v3/mail/send", "https://app.mailroomly.com"), then request.Method = "POST", request.Body = mail.GetRequestBody(message) and sendgrid.API(request)
Java (sendgrid-java) SendGrid sg = new SendGrid(key); sg.setHost("app.mailroomly.com"); (the host without https://)
C# (SendGrid) new SendGridClient(new SendGridClientOptions { ApiKey = key, Host = "https://app.mailroomly.com" })

Request fields

Every field SendGrid documents is either honored or refused with a 400 naming it; none is silently ignored. A setting sent with "enable": false is always accepted. Each setting is an object whose enable is true or false (sandbox_mode must carry it); any other shape is refused, never read as off.

Field Supported Notes
personalizations yes Required, 1 to 500. Each one is its own message, with every check a native send has (below).
from yes Required, { "email", "name" }. The domain must be a verified domain of the key's tenant and within the key's scope. A personalization's own from replaces it for that message.
reply_to, reply_to_list yes One or the other, not both.
subject yes Required, in the request or in every personalization.
content yes text/plain and text/html, each at most once; at least one. Any other type is refused.
attachments yes content (base64), filename, type, disposition (attachment or inline), content_id (required for inline, used for <img src="cid:…">).
headers yes String values. A personalization's headers are added to the request's, and replace one of the same name. Reserved headers are refused as on the native API.
categories yes At most 10, each 1 to 100 characters; stored as the message's tags and returned as category in webhooks.
custom_args yes String values; stored as the message's metadata and returned at the top level of every webhook event. A personalization's custom_args are added to the request's, and replace one of the same name.
asm yes Sends the message as marketing (broadcast): the recipient can unsubscribe, from all of the organization's marketing, and every copy carries one-click unsubscribe headers. group_id is required; groups are not kept (groups_to_display is accepted and not used).
mail_settings.sandbox_mode yes Runs the checks a real send makes before it keeps anything (the fields, the addresses and whether the recipients can receive mail, the sender domain and stream, the key's scopes), then keeps and sends nothing: 200 with no body, no message, no event, no webhook, nothing counted. The daily limits, and whether a resting organization may send, are not checked.
mail_settings.* (other) off only bypass_list_management, bypass_spam_management, bypass_bounce_management, bypass_unsubscribe_management: blocked addresses are always left out, for the recipients' sake. footer: put the footer in the content.
tracking_settings.open_tracking yes enable turns open tracking on or off for the message. substitution_tag is refused: the open pixel goes at the end of the HTML.
tracking_settings.click_tracking yes enable turns click tracking on or off. enable_text is accepted and not used.
tracking_settings.subscription_tracking, ganalytics off only Send with asm and put <%asm_group_unsubscribe_raw_url%> where the unsubscribe link goes; add analytics parameters to the links yourself.
template_id, send_at, batch_id, ip_pool_name, sections no Refused: render the content before sending, send when the message should go, and let the platform choose how each message leaves.

In a personalization:

Field Supported Notes
to, cc, bcc yes [{ "email", "name" }]; at least one to. At most 50 recipients in a personalization.
subject, from, headers, custom_args yes As above, for this message.
substitutions yes Each key (not empty) is replaced with its value in the subject, the text and the HTML, in the order given. Each key searches the whole text: a request's searches come to at most 20 times the batch limit (1 GB), so many substitutions over long content are refused with 400.
send_at, dynamic_template_data no Refused, as above.

Without asm, a message is transactional; with a key restricted to broadcast it is broadcast whatever the request says. On a plan with the Mailroomly footer (Free), a broadcast gets a small Sent with Mailroomly line at the end of its content.

In broadcast messages, <%asm_group_unsubscribe_raw_url%>, <%asm_global_unsubscribe_raw_url%> and <%asm_preferences_raw_url%> (as written, or HTML-escaped inside an attribute) are replaced with the recipient's hosted unsubscribe link, as the platform's {{unsubscribe_url}} is; in transactional messages they are removed.

Responses

Success: 202 Accepted, no body, and the call's id in X-Message-Id:

HTTP/1.1 202 Accepted
X-Message-Id: 01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50

Each personalization is one message in the platform (its own messageId, in the message log and the native API). They share the call's X-Message-Id, which SendGrid-format webhooks carry at the start of sg_message_id. Blocked recipients are left out of a message that still has other recipients, and a personalization whose recipients are all blocked is accepted and sends nothing, as SendGrid drops them; with SendGrid's webhook format, each of them is a dropped event.

sandbox_mode on: 200 OK with no body and no X-Message-Id, once every personalization passed the checks.

Errors: SendGrid's shape, one entry per refused personalization (field is personalizations.<index> when the request has several):

{ "errors": [ { "message": "The sender domain mail.example.com is not verified yet", "field": null, "help": null } ] }
Situation HTTP
A field refused or malformed (above), the body not JSON, more than 500 personalizations, personalizations whose content and attachments come to more than 50 MB once their substitutions are made (what they share counted once for each, as a batch of as many messages would carry it), or whose substitutions search more than 1 GB of text, any other validation error 400
No key, or an invalid, revoked or suspended-tenant key ("The provided authorization grant is invalid, expired, or revoked") 401
Sender domain unknown or not verified, key without SEND or outside its domain or stream scope, stream or sending paused, marketing that isn't allowed (from a starter address, without DMARC, outside the plan, before the terms are accepted), or a Free organization waking from resting 403
Request body over 15 MB, refused before it is parsed 413
More than 100 requests per second for the key (Retry-After: 1), the daily recipient limit, the key's own daily limit, or a Free plan's month of emails used up 429
Too many bodies at once (32 KB or more each), after waiting up to 10 seconds (Retry-After: 10); nothing is stored, send it again 429
An unexpected error 500

Every personalization is checked before any is sent, as SendGrid does: a call with one invalid personalization (an address, recipients who can't receive mail, the sender domain, a key's scope, the size) is refused whole, and nothing is sent. What only sending finds can still stop part of a call, above all a limit reached on the way (429): the answer then carries X-Message-Id and a last error saying how many were accepted; only those listed in errors were not sent, so retry just those.

Not supported

  • SendGrid's other APIs: templates, marketing campaigns and contacts, suppressions and unsubscribe groups, statistics, the activity feed, IP pools, subusers, sender verification, domain authentication, and the webhook and Inbound Parse settings. Use the native REST API, the web app and MCP instead: templates, contacts and newsletters live in Campaigns.
  • Scheduled sends and their batches, dynamic templates and legacy sections.
  • SendGrid's ids: the X-Message-Id and every message id are UUIDs.

SendGrid webhook format

Set the app's webhook format to SENDGRID (Integrations → (app), or webhookFormat: "SENDGRID"). Events then arrive as SendGrid's Event Webhook, signed as SendGrid signs it, and mail received by the app's addresses arrives as SendGrid's Inbound Parse. Every request is also signed with X-Messaging-Signature (webhooks).

A new app in this format subscribes to accepted and dropped too (processed and dropped in SendGrid's words); an app moved to it keeps its list, so add them if the handler uses them.

Event Webhook

Each delivery is a JSON array of events, as SendGrid posts them; one platform event is usually one SendGrid event (accepted is one per recipient).

[
  {
    "email": "joao@exemplo.pt",
    "timestamp": 1727432133,
    "smtp-id": "<01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50@mail.example.com>",
    "event": "delivered",
    "category": "invoice",
    "sg_event_id": "01926a3f-0c1d-7e2f-8a3b-4c5d6e7f8a9b",
    "sg_message_id": "01926a3e-7c1b-7d2e-9f40-000000000001.01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
    "response": "250 2.0.0 OK",
    "invoiceId": "123"
  }
]
Platform event SendGrid event Fields
accepted processed one per recipient
dropped dropped reason: Invalid (a recipient whose domain takes no mail, or a disposable address in marketing) or Suppressed Address (blocked); status 5.0.0
delivered delivered response: the recipient server's reply
delivery_delayed deferred response: why, as the recipient server said it
bounced bounce type bounce (permanent) or blocked (temporary), status (the reply's enhanced status code, else 5.0.0 or 4.0.0), reason; a recipient refused by validation before sending is dropped with reason Invalid
opened open useragent, sg_machine_open (false)
clicked click url, useragent
complained spamreport
unsubscribed unsubscribe
conversation — the native format (webhooks)

Every event carries email, timestamp (Unix seconds), smtp-id (the message's Message-ID), event, sg_event_id (unique per event: use it to de-duplicate), sg_message_id (<X-Message-Id>.<message id>; a message sent another way starts with its own id) and, when the message has them, category (a string for one, a list for several) and the message's custom_args at the top level (one named like a SendGrid field is left out).

Not sent: ip, tls, cert_err, attempt, asm_group_id, marketing_campaign_*, pool, and group_unsubscribe / group_resubscribe (an unsubscribe covers all of the organization's marketing).

Signature

SendGrid's signed Event Webhook: an ECDSA (P-256, SHA-256) signature of the timestamp followed by the body, in two headers.

Header Value
X-Twilio-Email-Event-Webhook-Signature The signature, base64 (DER).
X-Twilio-Email-Event-Webhook-Timestamp Unix seconds.

The app's verification key (the public key, base64) is on the app's page under Advanced details, and in webhookVerificationKey on GET /api/v1/apps/{id}. Put it where SendGrid's verification key was: SendGrid's helpers (@sendgrid/eventwebhook, sendgrid.helpers.eventwebhook, and the other libraries' EventWebhook) verify the platform's events unchanged. Rotating the app's secret (POST /api/v1/apps/{id}/webhook-secret/rotate) renews the key pair too, at once: events fail verification until the handler has the new key, so put it there right after rotating (the deliveries it refuses meanwhile are retried).

Inbound Parse and conversation deliveries carry no ECDSA signature (SendGrid signs neither); X-Messaging-Signature covers them.

Inbound Parse

Mail received by an address the app owns is posted as SendGrid's Inbound Parse (the parsed form, not raw), multipart/form-data:

Field Value
headers The message's headers, one per line.
to, cc, from The message's own headers, as they arrived (cc only when there is one).
subject, text, html The decoded subject and bodies, in UTF-8 (text and html when the message has them).
envelope JSON: to (the address that received it) and from (the envelope sender).
charsets JSON: every field is UTF-8.
attachments The number of files.
attachment-info JSON, by file field: filename, name, type, and content-id for an inline part.
content-ids JSON: each inline part's Content-ID and its file field.
attachment1 … attachmentN The files, with their filename and type.

Not sent: dkim, SPF, sender_ip and spam_score/spam_report: the platform doesn't pass on verdicts it didn't make in SendGrid's terms. Received mail goes to the app's webhook URL, the same as its events: if the handlers sit at two URLs, give received mail its own app, or the address its own inbound webhook URL (webhooks).