# 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](https://docs.svix.com/receiving/verifying-payloads/how) and [Standard Webhooks](https://www.standardwebhooks.com/) 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](https://hatang.dev/help/account/employees-and-roles)).
- Your endpoint must be reachable from the internet over HTTPS. To develop on your machine, see [Test locally](#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:

```bash
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"
  }'
```

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

```json
{
  "type": "email.delivered",
  "created_at": "2026-10-23T09:34:15.120Z",
  "data": {
    "email_id": "01a0b904-2978-73e8-b352-4a26fdca0ab5",
    "from": "Shop <hello@shop.vn>",
    "to": ["customer@example.com"],
    "subject": "Your order is confirmed",
    "created_at": "2026-10-23T09:34:12.952Z"
  }
}
```

| Module | Events |
|---|---|
| Email | `email.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` |
| Support | `chat.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` |
| Products | `chat.order.created`, `chat.order.updated`, `chat.order.paid` |
| CRM | `chat.zns.sent`, `chat.zns.failed` |
| Payments | `payment.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](https://hatang.dev/docs/reference/webhooks.md) describes every event and its `data`.

> **Note: 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](https://hatang.dev/docs/guides/payments/verify-ipn.md).

## 3. Verify the signature

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

```http
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=
```

| Header | Meaning |
|---|---|
| `svix-id` | The unique id of the event. It stays the same across retries and replays. |
| `svix-timestamp` | When this attempt was signed, in Unix seconds |
| `svix-signature` | One or more signatures separated by spaces, each `v1,<base64>` |
| `webhook-id`, `webhook-timestamp`, `webhook-signature` | The 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.

**Node.js**

`@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.

```js
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:

```js
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`](https://www.npmjs.com/package/svix) package works too: `new Webhook(secret).verify(rawBody, headers)`.

**Python**

```python
import os
from flask import Flask, request, abort
from hatang import HatangSignatureError, construct_webhook_event

SECRET = os.environ["HATANG_WEBHOOK_SECRET"]  # whsec_...
app = Flask(__name__)

@app.post("/webhooks/hatang")
def hatang_webhook():
    try:
        # raw bytes, before parsing; checks the signature and the 5-minute timestamp window
        event = construct_webhook_event(request.get_data(), request.headers, SECRET)
    except HatangSignatureError:
        abort(400)
    enqueue(request.headers["svix-id"], event)  # handle it in the background
    return "", 200
```

**PHP**

```php
<?php
function hatang_verify_webhook(string $rawBody, array $headers, string $secret, int $tolerance = 300): bool
{
    $h = array_change_key_case($headers, CASE_LOWER);
    $id = $h['svix-id'] ?? $h['webhook-id'] ?? null;
    $timestamp = $h['svix-timestamp'] ?? $h['webhook-timestamp'] ?? null;
    $signatures = $h['svix-signature'] ?? $h['webhook-signature'] ?? null;
    if (!$id || !$timestamp || !$signatures || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }
    $key = base64_decode(str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret);
    $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));
    foreach (explode(' ', $signatures) as $part) {
        [$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $sig)) {
            return true;
        }
    }
    return false;
}

$raw = file_get_contents('php://input');
if (!hatang_verify_webhook($raw, getallheaders(), getenv('HATANG_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}
$event = json_decode($raw, true);
// Queue $event for processing, then answer quickly.
http_response_code(200);
```

**Go**

```go
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func header(h http.Header, name string) string {
	if v := h.Get("svix-" + name); v != "" {
		return v
	}
	return h.Get("webhook-" + name)
}

func VerifyWebhook(body []byte, h http.Header, secret string) bool {
	id, ts, sigs := header(h, "id"), header(h, "timestamp"), header(h, "signature")
	if id == "" || ts == "" || sigs == "" {
		return false
	}
	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return false
	}
	if age := time.Since(time.Unix(sec, 0)); age > 300*time.Second || age < -300*time.Second {
		return false
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	for _, part := range strings.Split(sigs, " ") {
		version, sig, ok := strings.Cut(part, ",")
		if ok && version == "v1" && hmac.Equal([]byte(sig), []byte(expected)) {
			return true
		}
	}
	return false
}

func Handler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil || !VerifyWebhook(body, r.Header, os.Getenv("HATANG_WEBHOOK_SECRET")) {
		http.Error(w, "invalid signature", http.StatusBadRequest)
		return
	}
	// json.Unmarshal(body, &event), queue it, then answer.
	w.WriteHeader(http.StatusOK)
}
```

> **Caution: 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:

| Attempt | Delay |
|---|---|
| 1 | Immediately |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 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](#replay-a-delivery) it. Pending deliveries of an endpoint that is disabled or deleted are dropped.

## Replay a delivery {#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:

```bash
# 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 {#test-locally}

Ha Tang only delivers to public HTTPS addresses, so expose your local server through a tunnel such as [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) or [ngrok](https://ngrok.com/), and register the tunnel's URL as a separate endpoint:

```bash
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 {#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

- [Webhook events reference](https://hatang.dev/docs/reference/webhooks.md)
- [Idempotency](https://hatang.dev/docs/guides/getting-started/idempotency.md)
- [Security](https://hatang.dev/docs/guides/getting-started/security.md)
- [Payments webhooks](https://hatang.dev/docs/guides/payments/webhooks.md)

Source: https://hatang.dev/docs/guides/getting-started/webhooks
