> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tracklysms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Signing & Verification

> Every partner webhook is signed with HMAC-SHA256. Verify the signature and dedupe on event_id before trusting a payload.

Partner webhooks are account-level endpoints you register through the [webhook management API](/api-reference/v2/webhooks/create-endpoint). Every event Trackly delivers to them is signed with **HMAC-SHA256** so you can prove it came from Trackly and wasn't replayed or tampered with.

<Note>
  This is the **partner webhook control plane** — account-level endpoints, HMAC-signed, retried with backoff, managed over the API. It is a separate system from the per-list [Delivery Forwarding webhooks](/api-reference/v2/webhooks/events) configured in the dashboard (which use a shared-secret header and are not retried). This page describes the control-plane system.
</Note>

## Event types

A registered endpoint subscribes to one or more of:

| Event                                    | Fires when                                                                                   |
| ---------------------------------------- | -------------------------------------------------------------------------------------------- |
| `message.delivered`                      | A delivery receipt confirms the message reached the handset.                                 |
| `message.failed`                         | The message failed, was rejected, expired, or was undeliverable.                             |
| `message.reply`                          | A contact replied to your list number.                                                       |
| `contact.opted_out`                      | A contact opted out (STOP keyword, spam complaint, or repeated hard failures).               |
| `business_profile.verified`              | A [business profile](/api-reference/v2/business-profiles/lifecycle) passed KYB verification. |
| `business_profile.rejected`              | A business profile failed verification.                                                      |
| `business_profile.name_confirm_required` | A close match — the legal name must be confirmed.                                            |
| `business_profile.review_required`       | A business profile was flagged for manual review.                                            |

<Note>
  `business_profile.*` events carry `business_profile_id` and `verification_status` in their `data` object — fetch the [profile](/api-reference/v2/business-profiles/get-profile) or its [submissions](/api-reference/v2/business-profiles/list-submissions) for the full detail.
</Note>

## The event envelope

Each POST body is a single event (not a batch). Content type is `application/json`.

```json theme={null}
{
  "event_id": "b2e0f7c8-4a1d-4b2e-9c3a-1f2e3d4c5b6a",
  "event_type": "message.delivered",
  "created_at": "2026-07-26T14:03:11.123456+00:00",
  "attempt": 1,
  "schema_version": "2026-07-01",
  "account_external_ids": {
    "external_partner_id": "p_1",
    "external_location_id": "loc_west"
  },
  "data": {
    "message_id": "a1b2c3d4",
    "provider_message_id": "O3iFf1Eu",
    "from": "+18005551234",
    "to": "+14155551234",
    "status": "delivered"
  }
}
```

* **`event_id`** is minted once per logical event and shared across all endpoints, retries, and replays — **dedupe on it** (see below).
* **`account_external_ids`** carries the [external IDs](/api-reference/v2/accounts/overview#external-ids) of the child the event belongs to, so a single receiver can route across your whole portfolio.
* **`attempt`** starts at `1` and increments on each retry.

## Signature headers

| Header                     | Value                                                                                           |
| -------------------------- | ----------------------------------------------------------------------------------------------- |
| `X-Trackly-Signature`      | `t=<unix_timestamp>,v1=<hmac_hex>` — the signature to verify.                                   |
| `X-Trackly-Signature-Prev` | Same format, signed with the **previous** secret — present only during a rotation grace window. |
| `X-Webhook-Timestamp`      | The `<unix_timestamp>` (also embedded in `X-Trackly-Signature`).                                |
| `X-Trackly-Event`          | The `event_type`.                                                                               |
| `User-Agent`               | `Trackly-SMS-Webhooks/1.0`                                                                      |

## How to verify

1. Read `X-Trackly-Signature` and parse the `t` (timestamp) and `v1` (signature) parts.
2. Reject the request if `t` is more than **300 seconds** from your current time — this bounds replay.
3. Compute `HMAC-SHA256(signing_secret, "{t}.{raw_body}")` as a hex string, where `raw_body` is the **exact raw request body bytes** (do not re-serialize the parsed JSON — key order and whitespace must match).
4. Compare your computed value to `v1` using a **constant-time** comparison.
5. During a secret rotation, if `v1` doesn't match, repeat the check against `X-Trackly-Signature-Prev` with your previous secret before rejecting.

The signing secret is returned once when you create the endpoint (and again on rotation). Store it securely.

<CodeGroup>
  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import time
  from flask import request, abort

  SIGNING_SECRET = "your_endpoint_signing_secret"
  MAX_AGE_S = 300


  def verify(raw_body: bytes, signature_header: str) -> bool:
      parts = dict(p.split("=", 1) for p in signature_header.split(","))
      ts, sig = parts.get("t"), parts.get("v1")
      if not ts or not sig:
          return False
      if abs(time.time() - int(ts)) > MAX_AGE_S:
          return False
      expected = hmac.new(
          SIGNING_SECRET.encode(), f"{ts}.{raw_body.decode()}".encode(), hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, sig)


  @app.post("/trackly/webhooks")
  def receive():
      if not verify(request.get_data(), request.headers.get("X-Trackly-Signature", "")):
          abort(401)
      event = request.get_json()
      # dedupe on event["event_id"], then process, then return 2xx quickly
      return "", 200
  ```

  ```javascript Node.js (Express) theme={null}
  const crypto = require("crypto");

  const SIGNING_SECRET = "your_endpoint_signing_secret";
  const MAX_AGE_S = 300;

  function verify(rawBody, signatureHeader) {
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((p) => p.split("="))
    );
    const { t, v1 } = parts;
    if (!t || !v1) return false;
    if (Math.abs(Date.now() / 1000 - Number(t)) > MAX_AGE_S) return false;
    const expected = crypto
      .createHmac("sha256", SIGNING_SECRET)
      .update(`${t}.${rawBody}`)
      .digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(v1);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  // Mount with the raw body available, e.g. express.raw({ type: "application/json" })
  app.post("/trackly/webhooks", (req, res) => {
    if (!verify(req.body.toString(), req.get("X-Trackly-Signature") || "")) {
      return res.sendStatus(401);
    }
    const event = JSON.parse(req.body.toString());
    // dedupe on event.event_id, then process, then return 2xx quickly
    res.sendStatus(200);
  });
  ```
</CodeGroup>

## Delivery semantics

* **At-least-once and unordered.** A carrier receipt can be redelivered and a status can be revised (e.g. `delivered` later corrected to `failed`). **Dedupe on `event_id`**, and let the latest received state win.
* **Retries with backoff.** A non-`2xx` response (or a timeout — your endpoint must answer within **10 seconds**) is retried up to 5 times with delays of roughly **1m, 5m, 15m, 1h, 4h**. After the budget is exhausted the delivery is marked `dead`.
* **Replayable.** Every attempt is logged and can be re-sent from the [deliveries API](/api-reference/v2/webhooks/list-deliveries), reusing the original `event_id`.
* **HTTPS only.** Endpoints must be `https://` public URLs; Trackly pins the resolved IP and does not follow redirects.

## Rotating the signing secret

[Rotate the secret](/api-reference/v2/webhooks/rotate-secret) and the old secret keeps working for a **24-hour grace window**. During that window events are signed with the new secret in `X-Trackly-Signature` and the old secret in `X-Trackly-Signature-Prev`, so you can deploy the new secret with zero missed events. After the window, only the new secret is used.

## Related

<CardGroup cols={2}>
  <Card title="Create an endpoint" icon="gear" href="/api-reference/v2/webhooks/create-endpoint">
    Register an HTTPS endpoint to receive events.
  </Card>

  <Card title="Deliveries & replay" icon="rotate-right" href="/api-reference/v2/webhooks/list-deliveries">
    Inspect the delivery log and replay events.
  </Card>
</CardGroup>
