Qsendyx

Webhooks

Register an HTTPS endpoint and receive delivery events. Verify the signature, apply idempotency, and let status come to you.

Register an endpoint

POST /webhooks registers an HTTPS endpoint and returns a secret, shown once. GET /webhooks lists them, POST /webhooks/{id}/rotate issues a new secret, POST /webhooks/{id}/enable re-enables one, and DELETE /webhooks/{id} removes it.

cURL
curl -X POST https://api.qsendyx.com/api/v1/webhooks \
  -H "Authorization: Bearer $QSENDYX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/hooks/qsendyx",
    "events": ["message.delivered", "message.bounced", "message.complained"]
  }'
json
{
  "id": "019f70a4-3c8e-7d21-b0f4-51ac9e77b201",
  "url": "https://yourapp.com/hooks/qsendyx",
  "events": ["message.delivered", "message.bounced", "message.complained"],
  "enabled": true,
  "secret": "whsec_2b41f8...shown_once",
  "created_at": "2026-07-17T18:05:31Z"
}
The secret appears once. Store it now — a rotate is the only way to get a new one.

Events

json
{
  "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 the signature

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.

Deliveries and retries

Every attempt is inspectable through GET /webhooks/{id}/deliveries, and POST /webhooks/deliveries/{delivery_id}/retry replays one by hand. Failed deliveries are retried automatically with backoff, so a brief outage on your side does not lose events.