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 -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"]
}'{
"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"
}Events
{
"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"
}
}| Event | Meaning |
|---|---|
| message.accepted | The provider took the message. |
| message.sent | Handed off to the destination. |
| message.delivered | Confirmed delivered. |
| message.deferred | Temporarily postponed; still in flight. |
| message.bounced | Permanently rejected by the recipient server. |
| message.failed / message.rejected | Could not be sent. |
| message.suppressed | Recipient was on the suppression list; nothing was sent. |
| message.opened / message.clicked | Engagement. Does not change delivery status. |
| message.complained | Marked as spam. Treat as a hard signal. |
| message.unsubscribed | Opted 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));
}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.