Quathos Mailer

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
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>" }]
  }'
This page does not promise total parity. It lists, field by field, what is honored and what is refused — and why. Accepting a field in silence and dropping it is worse than refusing it: you migrate confident and find out from the first unsubscribed contact who got mail again. Everything we do not honor returns 400 with a message that says what to use instead.

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.

json
{
  "errors": [
    {
      "message": "The sender is not verified.",
      "field": "sender_not_verified",
      "help": "Verify the domain or sender at /painel/email/dominios, then retry."
    }
  ]
}
Errors come in the origin's envelope. When the error is not about a specific field, field carries the qsendyx error code — branch on it. X-Request-Id still comes on every response.

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 fieldWhat happens
personalizationsEach (personalization, to recipient) pair becomes an independent message with its own id, status and cost.
personalizations[].toThe recipient. name goes into the To header.
personalizations[].fromOverrides the global from for this personalization. Must be a verified sender, like any send.
personalizations[].subjectOverrides the global subject for this personalization.
personalizations[].dynamic_template_dataBecomes our template variables. Requires template_id.
personalizations[].custom_argsMerged into metadata, overriding the global custom_args on repeated keys — the same precedence as the origin.
fromThe sender. name goes into the From header.
subjectDefault subject for the batch, used by every personalization without its own.
contenttext/plain becomes the text body and text/html the HTML body. Other MIME types are refused.
reply_toReply address, with display name.
reply_to_listMultiple reply addresses. Mutually exclusive with reply_to, as at the origin.
template_idMust be the UUID of one of OUR templates. The origin's d-… id does not exist here: import the template first.
categoriesBecome the message's tags: they appear in listings, panel filters and event webhooks.
custom_argsBecomes the message's metadata, returned whole in every event webhook.
tracking_settings.click_tracking.enableTurns link rewriting on or off.
tracking_settings.open_tracking.enableTurns the open pixel on or off.
mail_settings.sandbox_modeOnly enable: false. With true it is refused: the equivalent here is a qmx_test_ token, which walks the whole pipeline without delivering or charging.
attachmentsBecomes 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_atUnix 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_atOverrides the global send_at for that personalization, with the same ceilings.
Refused field (400)Why — and what to do
personalizations[].ccThere 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[].bccSame reason as cc. Send one message per recipient.
personalizations[].headersArbitrary per-personalization headers are not supported. Use custom_args, which becomes metadata and returns in webhooks.
personalizations[].substitutionsLiteral substitution is the origin's legacy mechanism. Use dynamic_template_data with one of our templates.
headersArbitrary headers are not supported. reply_to has its own field; the rest belongs in custom_args/metadata.
batch_idThere is no batch cancellation. Cancel message by message at POST /api/v1/email/messages/{id}/cancel while it has not been submitted.
asmUnsubscribe groups do not exist here: suppression is per tenant and applies to every send. See POST /api/v1/suppressions.
ip_pool_nameIP pools are not client-configurable yet.
mail_settings.bypass_list_managementSkipping the suppression list is forbidden: someone who unsubscribed does not receive mail, and that is not the sender's call.
mail_settings.bypass_spam_managementSame reason as bypass_list_management.
mail_settings.bypass_bounce_managementSame reason: an address that hard-bounced stays suppressed — insisting burns everyone's reputation.
mail_settings.bypass_unsubscribe_managementSame 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.footerWe do not inject an automatic footer. Put it in the body or in the template.
tracking_settings.click_tracking.enable_textWe do not distinguish link rewriting in the text body from the HTML body — enable applies to both.
tracking_settings.open_tracking.substitution_tagA custom pixel position is not supported; it goes at the end of the body.
tracking_settings.subscription_trackingThe unsubscribe link is managed by us (List-Unsubscribe and the marketing footer), not configurable per send.
tracking_settings.ganalyticsUTM parameters are not injected automatically. Put them in your HTML links.
Only POST /v3/mail/send has a port. The origin's other routes — contacts, suppressions, templates, statistics — have no equivalent in this layer: your existing suppressions and templates come over through the migration importer in the panel, not through API compatibility. Handlebars templates ({{#if}}, {{#each}}, this.x) can be pasted as they are: the engine detects the dialect and translates it on the way to the sandbox; outside the supported subset the answer is a 4xx that says what to use — never a 202 with the block dropped.