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-Idand 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).