SendGrid compatibility
POST /v3/mail/send with the payload you already build — swap the base URL and the key. Field by field, every input is honored or refused with a reason; nothing is silently dropped.
Swap the base URL
The compatibility port lives at the root — https://api.qsendyx.com/v3/mail/send, not under /api/v1 — so the path is identical to the one your code already calls. Authentication is the same header, Authorization: Bearer, with a qsendyx token carrying the email:send scope. Success is a 202 with an empty body and the id in the X-Message-Id header, as at the origin.
curl -X POST https://api.qsendyx.com/v3/mail/send \
-H "Authorization: Bearer $QUATHOS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"personalizations": [{ "to": [{ "email": "user@example.com" }] }],
"from": { "email": "you@yourcompany.com", "name": "Your Company" },
"subject": "Your receipt",
"content": [{ "type": "text/html", "value": "<h1>Thanks!</h1>" }]
}'What changes in behaviour
One recipient per message. Each (personalization, to address) pair becomes an independent message with its own id, status, cost and suppression — it is what lets you answer "was this person's email delivered?". Since there are N ids and the origin returns one, the response also carries X-Qsy-Message-Ids with the full list, in envelope order. Each id can be read at GET /api/v1/email/messages/{id}.
The cap is 1000 recipients per request, adding up all personalizations — above that, 400: split the batch. Everything that enters through this port is transactional (the origin's marketing sends live in another API, which has no port here); for marketing, use POST /api/v1/email/messages with type: marketing. Idempotency-Key is accepted, even though the origin has no equivalent. A malformed payload is a 400, not a 422 — as at the origin.
{
"errors": [
{
"message": "The sender is not verified.",
"field": "sender_not_verified",
"help": "Verify the domain or sender at /painel/email/dominios, then retry."
}
]
}Field by field
The matrix below is the contract. A field is honored — and the second column says exactly what happens to it — or it is refused with 400 and a message that says what to use instead. There is no third state.
| Honored field | What happens |
|---|---|
| personalizations | Each (personalization, to recipient) pair becomes an independent message with its own id, status and cost. |
| personalizations[].to | The recipient. name goes into the To header. |
| personalizations[].from | Overrides the global from for this personalization. Must be a verified sender, like any send. |
| personalizations[].subject | Overrides the global subject for this personalization. |
| personalizations[].dynamic_template_data | Becomes our template variables. Requires template_id. |
| personalizations[].custom_args | Merged into metadata, overriding the global custom_args on repeated keys — the same precedence as the origin. |
| from | The sender. name goes into the From header. |
| subject | Default subject for the batch, used by every personalization without its own. |
| content | text/plain becomes the text body and text/html the HTML body. Other MIME types are refused. |
| reply_to | Reply address, with display name. |
| reply_to_list | Multiple reply addresses. Mutually exclusive with reply_to, as at the origin. |
| template_id | Must be the UUID of one of OUR templates. The origin's d-… id does not exist here: import the template first. |
| categories | Become the message's tags: they appear in listings, panel filters and event webhooks. |
| custom_args | Becomes the message's metadata, returned whole in every event webhook. |
| tracking_settings.click_tracking.enable | Turns link rewriting on or off. |
| tracking_settings.open_tracking.enable | Turns the open pixel on or off. |
| mail_settings.sandbox_mode | Only enable: false. With true it is refused: the equivalent here is a qmx_test_ token, which walks the whole pipeline without delivering or charging. |
| attachments | Becomes a MIME part of the message. disposition and content_id are respected: inline goes into multipart/related and can be referenced with cid:. Limits are lower than the origin — 10 files, 10 MB each and 15 MB combined, measured on the DECODED content, while SendGrid measures 30 MB on the base64 payload (which leaves at ~40 MB on the wire and does not pass Gmail). Extensions the large receivers discard on arrival (.exe, .js, .bat, …) are refused with 400. |
| send_at | Unix timestamp, as in the origin. The ceiling is 30 days (the cost is reserved from acceptance), or less with attachments — the attached bytes expire first. A time in the past means send now. |
| personalizations[].send_at | Overrides the global send_at for that personalization, with the same ceilings. |
| Refused field (400) | Why — and what to do |
|---|---|
| personalizations[].cc | There is no visible copy: one message has one recipient, so "was this person's email delivered?" has an answer. Translating cc into separate sends would change the semantics in silence — the copied person would never see they were copied. Send one message per recipient. |
| personalizations[].bcc | Same reason as cc. Send one message per recipient. |
| personalizations[].headers | Arbitrary per-personalization headers are not supported. Use custom_args, which becomes metadata and returns in webhooks. |
| personalizations[].substitutions | Literal substitution is the origin's legacy mechanism. Use dynamic_template_data with one of our templates. |
| headers | Arbitrary headers are not supported. reply_to has its own field; the rest belongs in custom_args/metadata. |
| batch_id | There is no batch cancellation. Cancel message by message at POST /api/v1/email/messages/{id}/cancel while it has not been submitted. |
| asm | Unsubscribe groups do not exist here: suppression is per tenant and applies to every send. See POST /api/v1/suppressions. |
| ip_pool_name | IP pools are not client-configurable yet. |
| mail_settings.bypass_list_management | Skipping the suppression list is forbidden: someone who unsubscribed does not receive mail, and that is not the sender's call. |
| mail_settings.bypass_spam_management | Same reason as bypass_list_management. |
| mail_settings.bypass_bounce_management | Same reason: an address that hard-bounced stays suppressed — insisting burns everyone's reputation. |
| mail_settings.bypass_unsubscribe_management | Same reason: whoever clicked unsubscribe does not come back by the sender's choice — and one-click unsubscribe is a Gmail and Yahoo requirement since 2024. |
| mail_settings.footer | We do not inject an automatic footer. Put it in the body or in the template. |
| tracking_settings.click_tracking.enable_text | We do not distinguish link rewriting in the text body from the HTML body — enable applies to both. |
| tracking_settings.open_tracking.substitution_tag | A custom pixel position is not supported; it goes at the end of the body. |
| tracking_settings.subscription_tracking | The unsubscribe link is managed by us (List-Unsubscribe and the marketing footer), not configurable per send. |
| tracking_settings.ganalytics | UTM parameters are not injected automatically. Put them in your HTML links. |