← Back to resources

Builder resources

Qsendyx API v1 reference

Endpoints, authentication, scopes, idempotency, error format and webhook signatures for the public v1 API.

15 min read

The public API is REST over JSON, versioned in the path. Everything below is available to any token with the right scope, in both the test and live environments.

Base URL and versioning

All endpoints live under https://api.qsendyx.com/api/v1. The version is part of the path, so a breaking change means a new prefix — v1 keeps its contract for as long as it exists.

Requests and responses are JSON. Timestamps are UTC, ISO 8601, with an explicit offset. Identifiers are UUIDs, and you can safely store them as opaque strings.

Authentication

Every authenticated call carries a bearer token. The prefix tells you which environment it belongs to: qsy_test_ never reaches a real recipient, qsy_live_ does and spends balance.

Authorization: Bearer qsy_live_91fb5d2dc31fd065...

Authorisation is the intersection of two axes: what the token may do (its scopes) and what the person who created it may do (their role). A read-only member cannot mint a sending token, and a sending token created by an owner is still limited to sending.

Idempotency

Send an Idempotency-Key header on every write that costs money. If the same key arrives twice — a retry after a timeout, a duplicated job — you get the original response back instead of a second message and a second charge.

POST /api/v1/email/messages
Idempotency-Key: 5c9b2a10-4f83-4c2e-9b77-2a1d0e6b8f44
Use a fresh UUID per logical send, not per HTTP attempt. Reusing the key across genuinely different messages silently returns the first result and drops the second.

Send email

POST /email/messages accepts either raw content or a template, never both. With raw content, subject is required alongside html or text. With template_id, the body comes from the active version of the template and variables fills the placeholders.

curl -X POST https://api.qsendyx.com/api/v1/email/messages \
  -H "Authorization: Bearer $QSENDYX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "from":    { "email": "you@yourcompany.com", "name": "Your Company" },
    "to":      [{ "email": "user@example.com" }],
    "subject": "Welcome aboard",
    "html":    "<h1>You are in.</h1>"
  }'
{
  "from":        { "email": "you@yourcompany.com" },
  "to":          [{ "email": "user@example.com" }],
  "template_id": "019f70c2-2b41-7a5e-9c3e-2f1a8d55b310",
  "variables":   { "name": "Ana", "order_id": "A-1042" }
}
Template mode — no html, no text.
FieldNotes
fromObject with email and optional name. Must be a verified domain or sender.
toArray of recipients, at least one.
subjectRequired unless template_id is present, where it overrides the template.
html / textRaw content. Mutually exclusive with template_id.
template_id / variablesTemplate mode. Templates are sandboxed — they never execute code.
typetransactional (default) or marketing. Separate identities, separate reputation.
send_atSchedules the send. UTC.
tags / metadataYours. Returned on events, useful for correlation.
trackingPer-message opens and clicks toggles.
{
  "id": "019f7141-46ac-76c3-8276-042f38c68cd8",
  "status": "queued",
  "channel": "email",
  "to": "user@example.com",
  "from": "you@yourcompany.com",
  "type": "transactional",
  "created_at": "2026-07-17T18:05:31Z",
  "estimated_cost_micros": 0,
  "currency": "USD"
}
202 Accepted. GET /email/messages/{id} returns the same shape, updated.
Money is in micro-dollars: 1 USD = 1,000,000 micros, always an integer. An email costs on the order of US$ 0.0008, which whole cents would round to zero. estimated_cost_micros is 0 when the send falls inside your plan quota — it was already paid by the subscription. Above the quota it carries your plan overage rate, or your credits absorb it.

Send SMS

POST /sms/messages takes from, to and body. The recipient is normalised to E.164, so pass the country code.

curl -X POST https://api.qsendyx.com/api/v1/sms/messages \
  -H "Authorization: Bearer $QSENDYX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "from": "QSENDYX",
    "to":   "+5511999998888",
    "body": "Seu código é 481902"
  }'

The response includes the segment breakdown, and that is what you are billed on. A message is not one SMS — it is as many segments as the encoding requires.

{
  "id": "019f7150-8a11-7c02-b6d1-9f0c4a2e77aa",
  "status": "queued",
  "channel": "sms",
  "segments": { "count": 2, "encoding": "ucs2", "warning": "non_gsm7_characters" },
  "estimated_cost_micros": 48000,
  "currency": "USD"
}
GSM-7 does not cover Portuguese. A single á, ã or lowercase ç forces the whole message into UCS-2, which halves the characters per segment and doubles the cost. Never estimate price from string length — read segments.count from the response.

GET /sms/messages lists sends, /sms/messages/{id}/events returns the event trail, and /sms/messages/{id}/cancel stops a scheduled message that has not been submitted yet.

Verification and OTP

The Verify endpoints handle one-time codes end to end: generation, expiry, attempt limits and validation. You never store the code.

curl -X POST https://api.qsendyx.com/api/v1/verify/services/{service_id}/verifications \
  -H "Authorization: Bearer $QSENDYX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+5511999998888", "channel": "sms" }'
Start a verification.
curl -X POST https://api.qsendyx.com/api/v1/verify/services/{service_id}/checks \
  -H "Authorization: Bearer $QSENDYX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+5511999998888", "code": "481902" }'
Check the code the user typed.
OTP traffic is a favourite target for pumping fraud — an attacker triggers thousands of verifications to premium destinations and collects a share of the termination fee. Rate-limit by user and by destination on your side too, not only by IP.

Domains and senders

POST /email/domains registers a domain and returns the DNS records to publish; POST /email/domains/{id}/verify checks them. For a single address rather than a whole domain, POST /email/senders sends a confirmation email to the address itself.

A send from an unverified identity is refused. This is not a formality: it is what stops one tenant from sending as another.

Webhooks

POST /webhooks registers an HTTPS endpoint and returns a secret, shown once. Deliveries are retried with backoff, and every attempt is inspectable through /webhooks/{id}/deliveries.

{
  "id": "evt_3f9a1c72b0d84e15a6c27f31",
  "type": "message.delivered",
  "version": "1",
  "occurred_at": "2026-07-17T18:05:36.918Z",
  "tenant_id": "019f70a4-3c8e-7d21-b0f4-51ac9e77b201",
  "project_id": "019f70a4-3c8e-7d21-b0f4-51ac9e77b3aa",
  "resource_id": "019f7141-46ac-76c3-8276-042f38c68cd8",
  "data": {
    "message_id": "019f7141-46ac-76c3-8276-042f38c68cd8",
    "status": "delivered",
    "channel": "email",
    "message_type": "transactional",
    "recipient": "user@example.com"
  }
}
EventMeaning
message.acceptedThe provider took the message.
message.sentHanded off to the destination.
message.deliveredConfirmed delivered.
message.deferredTemporarily postponed; still in flight.
message.bouncedPermanently rejected by the recipient server.
message.failed / message.rejectedCould not be sent.
message.suppressedRecipient was on the suppression list; nothing was sent.
message.opened / message.clickedEngagement. Does not change delivery status.
message.complainedMarked as spam. Treat as a hard signal.
message.unsubscribedOpted out. Suppressed automatically from then on.

Verify X-Webhook-Signature before using the payload. It is v1=<hex>, an HMAC-SHA256 over the timestamp and the raw body, keyed by the endpoint secret. Compare in constant time and reject anything older than five minutes.

import crypto from "node:crypto";

// O corpo BRUTO, antes de qualquer JSON.parse — reserializar muda os bytes e quebra o HMAC.
export function verify(rawBody, headers, secret) {
  const timestamp = headers["x-webhook-timestamp"];
  const signature = headers["x-webhook-signature"];

  // Sem a janela de tolerância, um webhook capturado é replicável para sempre.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!timestamp || age > 300) return false;

  const expected =
    "v1=" +
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.` + rawBody)
      .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
X-Webhook-Id is the event identifier and it is stable across retries. Store it and ignore repeats — at-least-once delivery means duplicates are normal, not a bug.

Rate limits

Limits are per credential, not per IP, so one noisy integration cannot starve another. The default ceiling is 120 requests per minute per token, and it can be lowered per token in the panel.

Over the limit you get 429. Back off exponentially and retry with the same Idempotency-Key — that is exactly the case the key exists for.

Errors

Errors are JSON with a stable machine-readable code. Branch on code, never on the message — messages are written for humans and they get rewritten.

{
  "error": {
    "code": "insufficient_scope",
    "message": "This token cannot send email.",
    "request_id": "req_8c1d0a3f5b724e90"
  }
}
StatusWhen
400Malformed body or a field that fails validation.
401Missing, malformed or revoked token.
403The token lacks the scope for this operation.
404Resource does not exist, or belongs to another tenant.
409Idempotency conflict — same key, different payload.
422Semantically invalid: unverified sender, suppressed recipient.
429Rate limit exceeded.
5xxOurs. Retry with the same idempotency key.
Always log request_id. It is the fastest way for support to find your exact call in our logs.

Keep going