Webhooks
A webhook tells your application what happened to a message after you sent
it. Duta sends an HTTPS POST to your endpoint for each event.
Add an endpoint
Section titled “Add an endpoint”In the dashboard under Webhooks, or with the API:
{ "endpoint": "https://kedai.my/webhooks/duta", "events": ["email.delivered", "email.bounced"] }Leave events out to receive every event. The response includes the
signing_secret, a whsec_ value shown only once. Store it with your other
secrets. An account can have up to 10 endpoints.
Manage endpoints
Section titled “Manage endpoints”- List endpoints:
GET /v1/webhooks - Get an endpoint:
GET /v1/webhooks/{id} - Delete an endpoint:
DELETE /v1/webhooks/{id}
Events
Section titled “Events”| Event | Sent when |
|---|---|
email.sent |
The mail provider accepted the message. |
email.delivered |
The recipient’s mail server accepted it. |
email.delivery_delayed |
Delivery is being retried after a temporary failure. |
email.bounced |
The recipient’s server refused it. A permanent bounce suppresses the address. |
email.complained |
The recipient marked it as spam. The address is suppressed. |
email.failed |
The message could not be sent. |
email.opened and email.clicked are accepted for Resend compatibility, but
Duta does not track opens or clicks, so they are never sent.
The payload
Section titled “The payload”{ "type": "email.bounced", "created_at": "2026-09-25T08:14:03.000Z", "data": { "email_id": "msg_...", "created_at": "2026-09-25T08:14:01.000Z", "from": "Kedai <resit@kedai.my>", "to": ["siti@example.com"], "subject": "Resit #1042", "bounce": { "type": "Permanent", "subType": "General", "message": "550 5.1.1 user unknown" } }}data.email_id is the id POST /v1/emails returned.
Verify the signature
Section titled “Verify the signature”Every delivery is signed. Check the signature before trusting the body, or anyone who finds your endpoint can post to it. Verify against the raw request body, before any JSON parsing.
Duta signs the way Resend does, following the Standard Webhooks spec, so the Resend and Svix libraries verify Duta’s deliveries unchanged:
import { Duta } from '@duta/sdk';
const duta = new Duta(process.env.DUTA_API_KEY);
// In your handler, with the raw body as a string:const event = await duta.webhooks.verify({ payload: rawBody, headers: req.headers, secret: process.env.DUTA_WEBHOOK_SECRET,}); // throws WebhookVerificationError if it is not from Dutaimport { Resend } from 'resend';
const resend = new Resend(process.env.DUTA_API_KEY);
// In your handler, with the raw body as a string:const event = resend.webhooks.verify({ payload: rawBody, headers: { id: req.headers['svix-id'], timestamp: req.headers['svix-timestamp'], signature: req.headers['svix-signature'], }, webhookSecret: process.env.DUTA_WEBHOOK_SECRET,});import { Webhook } from 'svix';
const wh = new Webhook(process.env.DUTA_WEBHOOK_SECRET);const event = wh.verify(rawBody, req.headers); // throws if the signature is wrongThe headers are webhook-id, webhook-timestamp and webhook-signature,
also sent as svix-id, svix-timestamp and svix-signature.
- Build the signed content:
{webhook-id}.{webhook-timestamp}.{raw body}. - Take the part of your secret after
whsec_and base64-decode it. That is the key. - Compute HMAC-SHA256 of the content with the key, and base64-encode it.
webhook-signatureholdsv1,followed by the signature. Compare in constant time.- Refuse a timestamp more than five minutes from now, so an old delivery cannot be replayed.
Duta also sends duta-signature, a hex HMAC-SHA256 of
{duta-timestamp}.{raw body} keyed with the whole secret, for integrations
written before the Standard Webhooks headers.
Retries
Section titled “Retries”Answer with any 2xx status within 10 seconds. Anything else counts as a
failure:
- A timeout, a connection failure, a
429or a5xxis retried with backoff: 30 seconds after the first failure, doubling each time up to an hour, for 10 attempts over about three hours. - Any other
4xxmeans your endpoint refused the event, so it is not retried. - After 20 failed deliveries in a row, the endpoint is disabled. Fix it, then enable it again in the dashboard or with
POST /v1/webhooks/{id}/enable.
A retry carries the same webhook-id as the first attempt. Use it to ignore
an event you already processed.
Every attempt, successful or not, is listed on the endpoint’s page in the
dashboard and at GET /v1/webhooks/{id}/deliveries.