Skip to content

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.

In the dashboard under Webhooks, or with the API:

POST /webhooks
{ "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.

  • List endpoints: GET /v1/webhooks
  • Get an endpoint: GET /v1/webhooks/{id}
  • Delete an endpoint: DELETE /v1/webhooks/{id}
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.

{
"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.

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 Duta

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.

Answer with any 2xx status within 10 seconds. Anything else counts as a failure:

  • A timeout, a connection failure, a 429 or a 5xx is 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 4xx means 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.