Skip to main content

Webhooks

Webhooks tell your server when something happens in Ha Tang: an email is delivered, a customer sends a chat message, a bank transfer pays an order. You register an HTTPS endpoint and pick the events you want. Ha Tang sends each event as a signed POST request, retries when your endpoint fails, and keeps a log of every delivery.

One endpoint can receive events from every module. Signatures follow the Svix and Standard Webhooks scheme, so existing libraries work.

Before you start​

  • You need to be an owner or admin, or a member with Developer access (Team and roles).
  • Your endpoint must be reachable from the internet over HTTPS. To develop on your machine, see Test locally.

1. Create an endpoint​

  1. In the dashboard, open Settings → Webhooks (also under Email → Webhooks).
  2. Click Add endpoint, enter the URL, for example https://shop.vn/webhooks/hatang, and tick the events to send.
  3. Copy the signing secret. It starts with whsec_ and is shown only once.

Or create it with the API, using a key that has any write scope:

curl -X POST https://api.hatang.dev/webhooks \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://shop.vn/webhooks/hatang",
"events": ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"],
"description": "Production backend"
}'
{
"object": "webhook",
"id": "wh_123...",
"endpoint": "https://shop.vn/webhooks/hatang",
"events": ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"],
"status": "enabled",
"description": "Production backend",
"created_at": "2026-10-23T09:34:12.952Z",
"signing_secret": "whsec_123..."
}

Store the secret in an environment variable such as HATANG_WEBHOOK_SECRET.

The endpoint URL must use https and point to a public host. URLs with a username or password, localhost, .local or .internal hosts and private IP addresses are refused with 422 validation_error. To pause deliveries, set the endpoint's status to disabled (PATCH /webhooks/{id}).

2. Choose events​

Every delivery has the same envelope: the event type, when it happened, and the object in data.

{
"type": "email.delivered",
"created_at": "2026-10-23T09:34:15.120Z",
"data": {
"email_id": "01a0b904-2978-73e8-b352-4a26fdca0ab5",
"from": "Shop <[email protected]>",
"to": ["[email protected]"],
"subject": "Your order is confirmed",
"created_at": "2026-10-23T09:34:12.952Z"
}
}
ModuleEvents
Emailemail.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked, email.failed, email.received, contact.created, contact.updated, contact.deleted, domain.created, domain.updated, domain.deleted
Supportchat.conversation.created, .assigned, .resolved, .closed, .reopened, .handoff, .tagged; chat.message.created; chat.visitor.identified; chat.ticket.created, .updated, .resolved, .breached; chat.rating.created; chat.flow.webhook; chat.campaign.lead
Productschat.order.created, chat.order.updated, chat.order.paid
CRMchat.zns.sent, chat.zns.failed
Paymentspayment.created, .succeeded, .partially_paid, .overpaid, .expired, .canceled; transaction.received; bank_connection.activated, .revoked, .needs_reauth; invoice.* (draft, issued, tax and correction events)

The webhook events reference describes every event and its data.

Payments environments

An endpoint receives Payments events from both the development and the production environment. Every Payments object has an environment field: check it before you fulfil an order. Payments can also call a per-payment ipn_url, which is signed differently; see Verify IPN.

3. Verify the signature​

Each delivery is a POST with a JSON body and these headers:

POST /webhooks/hatang HTTP/1.1
content-type: application/json
user-agent: hatang-webhooks/1.0
svix-id: msg_01a0b90429d87cf89e37fc2f66040ec5
svix-timestamp: 1761212055
svix-signature: v1,Kk3W5m0rB1o8V0qk4bq2Jb0nU4y1sOq4uQ0b6rjB0yE=
webhook-id: msg_01a0b90429d87cf89e37fc2f66040ec5
webhook-timestamp: 1761212055
webhook-signature: v1,Kk3W5m0rB1o8V0qk4bq2Jb0nU4y1sOq4uQ0b6rjB0yE=
HeaderMeaning
svix-idThe unique id of the event. It stays the same across retries and replays.
svix-timestampWhen this attempt was signed, in Unix seconds
svix-signatureOne or more signatures separated by spaces, each v1,<base64>
webhook-id, webhook-timestamp, webhook-signatureThe same values under the Standard Webhooks names

To verify a delivery:

  1. Take the raw body exactly as received, before any JSON parsing.
  2. Build the signed content <svix-id>.<svix-timestamp>.<raw body>.
  3. Remove the whsec_ prefix from the secret and base64-decode the rest: that is the HMAC key.
  4. Compute HMAC-SHA256 of the signed content and base64-encode it.
  5. Accept the delivery if it equals any v1 signature in svix-signature, using a constant-time comparison.
  6. Reject it if svix-timestamp is more than 300 seconds away from your clock. This blocks replays of old captured requests.

@hatang/sdk does all six steps. constructWebhookEvent returns the parsed event or throws HatangSignatureError; verifyWebhook returns true or false. Both accept the svix-* or webhook-* headers and a default tolerance of 300 seconds.

import express from "express";
import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk";

const app = express();

// express.raw keeps the body as a Buffer. Do not use express.json() on this route.
app.post("/webhooks/hatang", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await constructWebhookEvent(req.body, req.headers, process.env.HATANG_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof HatangSignatureError) return res.status(400).send("Invalid signature");
throw err;
}

await queue.add("hatang-event", { id: req.get("svix-id"), event }); // handle it in the background
res.sendStatus(200);
});

In a Next.js route handler or any runtime with the Fetch API:

import { verifyWebhook } from "@hatang/sdk";

export async function POST(request) {
const raw = await request.text();
if (!(await verifyWebhook(raw, request.headers, process.env.HATANG_WEBHOOK_SECRET))) {
return new Response("Invalid signature", { status: 400 });
}
const event = JSON.parse(raw);
// ...
return new Response(null, { status: 200 });
}

The svix package works too: new Webhook(secret).verify(rawBody, headers).

Verify the raw body

Always verify the bytes you received. Parsing the JSON and serializing it again changes spacing or key order and breaks the signature. In frameworks that parse JSON automatically, turn that off for the webhook route.

4. Respond quickly​

Answer with any 2xx status within 15 seconds. Anything else counts as a failure: another status, a timeout, a connection error, or a redirect (redirects are not followed). The first 4 KB of your response body is saved with the delivery, which helps when debugging.

Do the real work in a background job: verify, store or enqueue the event, answer 200, then process.

Retries​

When a delivery fails, Ha Tang tries again on this schedule, measured from the previous attempt:

AttemptDelay
1Immediately
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours
810 hours

After the eighth failed attempt, about 27.5 hours after the event, the delivery is marked as exhausted and not retried again. You can still replay it. Pending deliveries of an endpoint that is disabled or deleted are dropped.

Replay a delivery​

The endpoint's page in the dashboard lists recent Deliveries with the event, the attempt number, your endpoint's response and the Next retry time. Click Replay to send a delivery again, for example after you fix a bug in your handler.

With the API:

# The last 50 deliveries of an endpoint
curl https://api.hatang.dev/webhooks/wh_123.../deliveries \
-H "Authorization: Bearer $HATANG_API_KEY"

# Send one again
curl -X POST https://api.hatang.dev/webhooks/deliveries/whd_123.../replay \
-H "Authorization: Bearer $HATANG_API_KEY"

A replay goes through the same retry schedule and carries the same svix-id as the original, so a handler that deduplicates on it will not process the event twice.

Rotate the signing secret​

Rotate the secret if it may have leaked, or on a schedule. Click Rotate secret on the endpoint's page, or call POST /webhooks/{id}/rotate, which returns { "id": "wh_123...", "secret": "whsec_..." }.

For 24 hours after a rotation, every delivery is signed with both the new and the old secret: svix-signature contains two v1 signatures. Your current code keeps working while you deploy the new secret, and the verification code above accepts a delivery if any signature matches.

Test locally​

Ha Tang only delivers to public HTTPS addresses, so expose your local server through a tunnel such as Cloudflare Tunnel or ngrok, and register the tunnel's URL as a separate endpoint:

cloudflared tunnel --url http://localhost:3000
# or
ngrok http 3000

Then trigger a real event, such as sending an email to yourself, or replay an earlier delivery from the dashboard. Use the new endpoint's own signing secret on your machine, and delete the endpoint when you are done.

Make your handler idempotent​

Treat every delivery as "at least once":

  • Deduplicate on svix-id. The same event can arrive more than once, after a timeout on your side, a retry or a replay. Store the ids you have processed and skip repeats.
  • Do not rely on order. Retries can deliver an older event after a newer one. Compare created_at, or fetch the current state of the object from the API before acting on it.
  • Answer 2xx for events you ignore, so they are not retried for a day.
  • Check the environment of Payments events, so a development payment never fulfils a real order.

Next steps​

View this page as Markdown