## Webhooks

A webhook subscription is an HTTPS endpoint of yours that Talli calls when a resource of your organization changes. Each call is a signed `POST` with one event. Without a subscription you learn about a change by polling the API; with one, Talli tells you within seconds of the change.

Subscriptions are managed through the API under `/v3/distribution-manager/webhooks`. In the routes below, `…` stands for `/v3/distribution-manager`. The API reference documents every request and response.

| Endpoint | Scope | Purpose |
|  --- | --- | --- |
| `POST …/webhooks` | `webhooks:manage` | Create a subscription. Returns the signing secret. |
| `GET …/webhooks`, `GET …/webhooks/{id}` | `webhooks:view` | List or read subscriptions. Never returns the secret. |
| `PATCH …/webhooks/{id}` | `webhooks:manage` | Change the URL, the event types or the description. |
| `DELETE …/webhooks/{id}` | `webhooks:manage` | Delete a subscription. |
| `POST …/webhooks/{id}/:enable`, `POST …/webhooks/{id}/:disable` | `webhooks:manage` | Pause and resume a subscription. |
| `GET …/webhooks/{id}/secret` | `webhooks:manage` | Read the signing secret again. |
| `POST …/webhooks/{id}/:rotate-secret` | `webhooks:manage` | Issue a new signing secret. |
| `POST …/webhooks/{id}/:ping` | `webhooks:manage` | Send a test event to the endpoint. |
| `GET …/webhooks/event-types` | `webhooks:view` | The catalogue of event types. |
| `GET …/webhooks/{id}/deliveries`, `GET …/webhooks/{id}/deliveries/{deliveryId}` | `webhooks:view` | The delivery log. |
| `POST …/webhooks/{id}/deliveries/{deliveryId}/:redeliver` | `webhooks:manage` | Send a delivery again. |


A `FullAccess` credential carries both scopes, a `ReadOnly` credential carries `webhooks:view` only. A token requested before your credential gained the webhook scopes is refused with `403`; request a new token once. See [Credentials](/docs/get-started/credentials#new-scopes).

## Subscribe

Register the endpoint with the event types it should receive. The response is the only place, besides `GET …/webhooks/{id}/secret` and `:rotate-secret`, where the signing secret appears. Store it.

```bash
curl --request POST \
  --url 'https://api.talli.ai/v3/distribution-manager/webhooks' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{
    "url": "https://example.com/talli/webhooks",
    "eventTypes": ["payoutInstruction.created", "payoutInstruction.updated"],
    "description": "Production integration"
  }'
```

```json
{
  "id": "3f0a7c9e-5b1d-4e2a-9c8b-7d6e5f4a3b2c",
  "url": "https://example.com/talli/webhooks",
  "eventTypes": ["payoutInstruction.created", "payoutInstruction.updated"],
  "description": "Production integration",
  "status": "Active",
  "previousSecretExpiresOn": null,
  "createdOn": "2026-10-21T09:12:44.318Z",
  "updatedOn": "2026-10-21T09:12:44.318Z",
  "secret": "whsec_k3xQ9vD1mN7pR2sT5uW8yA0bC4dE6fG9hJ1kL3mN5oP"
}
```

Rules for the subscription:

- `url` is an absolute `https` URL of up to 2048 characters, without a user name or password. Its host must resolve to public addresses only: private, loopback, link-local and shared (CGNAT) addresses are rejected, when the subscription is created or updated and again before every delivery.
- `eventTypes` holds one or more types from `GET …/webhooks/event-types`.
- `description` is optional, up to 200 characters.
- Your organization can hold at most 10 subscriptions. The 11th create returns `409` with `errorCode` `WEBHOOK_SUBSCRIPTION_LIMIT_REACHED`.


Send a test event to check the endpoint before any real event reaches it:

```bash
curl --request POST \
  --url 'https://api.talli.ai/v3/distribution-manager/webhooks/YOUR_SUBSCRIPTION_ID/:ping' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
```

The call returns `202` with the `deliveryId` of a `ping` event whose `data` is `{ "subscriptionId": "…" }`. Read the delivery to see how the endpoint answered.

`:disable` pauses a subscription: events raised while it is disabled are not sent, and are not sent later either. A delivery that is still retrying fails without being sent if its next attempt falls while the subscription is disabled; if you enable the subscription before that attempt, the delivery goes on retrying as usual. `:enable` does not resend events raised while the subscription was disabled, and `:redeliver` sends a failed delivery again. `DELETE` removes the subscription; an event still waiting for a delivery to it fails without being sent. A `PATCH`, `DELETE`, `:enable`, `:disable` or `:rotate-secret` that overlaps another change to the same subscription returns `409` with `WEBHOOK_SUBSCRIPTION_CHANGED_CONCURRENTLY`; call again.

## Events

`GET …/webhooks/event-types` lists the types a subscription can receive. `ping` is not among them: it is sent only on request.

| Event type | Sent when | `data` |
|  --- | --- | --- |
| `payoutInstruction.created` | A payout instruction is created. | The payout instruction. |
| `payoutInstruction.updated` | A payout instruction changes after it was created: status, selected payout method, fulfilment, cancellation, an edit of its fields or tags. | The payout instruction. |
| `ping` | You call `:ping`. | `{ "subscriptionId": "…" }` |


`data` of a payout instruction event is the stored state of the resource at the moment of the change, in the shape of `GET …/payout-instructions/{id}`, without the fields that endpoint computes when it is read, such as `nextReminderDate`. There is one event type for every change, so filter on `data.status` for the transition you care about, for example `Fulfilled` or `Cancelled`.

Example `payoutInstruction.updated`:

```json
{
  "id": "6f1c0d3a-8e2b-4c5d-9a7f-1b3e5d7c9a2f",
  "type": "payoutInstruction.updated",
  "occurredAt": "2026-10-21T10:15:30.412Z",
  "apiVersion": "v3",
  "data": {
    "payoutInstructionId": "1a2b3c4d-e5f6-7890-1234-567890abcdef",
    "distributionId": "9c8b7a6f-5e4d-3c2b-1a09-8f7e6d5c4b3a",
    "bankAccountId": "0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b",
    "amount": 250.00,
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane.doe@example.com",
    "phoneNumber": null,
    "status": "Fulfilled",
    "cancellationReason": null,
    "selectedPayoutMethodId": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a",
    "fulfilledOn": "2026-10-21T10:15:30.398Z",
    "externalId": "claim-00042",
    "subClientId": null,
    "tags": { "batch": "2026-10" },
    "isDirectPayout": false
  }
}
```

## The delivery

Every delivery is a `POST` to the subscription URL with a JSON body and these headers:

| Header | Value |
|  --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `Talli-Webhooks/1` |
| `Talli-Event-Id` | The event id, the same as `id` in the body. Stable across retries and redeliveries. |
| `Talli-Event-Type` | The event type, the same as `type` in the body. |
| `Talli-Delivery-Id` | The id of this delivery, one per event and subscription. Use it with the delivery log. |
| `Talli-Signature` | The signature, see [Verify the signature](#verify-the-signature). |


The body is the envelope:

| Field | Description |
|  --- | --- |
| `id` | The event id, a UUID. One event is one occurrence; the same id can be delivered more than once. |
| `type` | The event type. |
| `occurredAt` | When the change happened, ISO 8601 in UTC with milliseconds. |
| `apiVersion` | `v3`. The shape of `data` follows this API version. |
| `data` | The payload of the event type. |


Answer with any `2xx` status within 10 seconds. The response body is ignored, except that its first 1 KB is kept in the delivery log for your debugging. Do your processing after you answer, or keep it short: a response that arrives after 10 seconds counts as no response. Talli does not follow redirects; a `3xx` is a failure.

Deliveries are at least once and not in order. The same event can arrive twice, also after your endpoint answered `2xx`: Talli retries when it saw no answer, you can redeliver from the log, and a newer event can arrive before a retry of an older one. Build the endpoint for that:

- Deduplicate on `Talli-Event-Id`. Keep the ids you have processed and answer `2xx` to one you have seen.
- Apply a payload only when its `occurredAt` is newer than the copy you hold for that `payoutInstructionId`. An older event that arrives late carries an older state.
- When you act on one transition, filter on `data.status`; a payout instruction sends many `updated` events over its life.


## Verify the signature

`Talli-Signature` proves that the delivery comes from Talli and was not altered. Verify it before you trust the body. Talli's outbound addresses are not fixed, so the signature, not an IP allow list, is the proof of origin.

```
Talli-Signature: t=1792577730,v1=c7e17e6eec00632623b5b6d26b01b548f0240a3996292c20ee238fd55bf9ebea
```

- `t` is the Unix time in seconds when the delivery was signed. Every attempt, a retry or a redelivery included, is signed at the moment it is sent, so `t` is always fresh.
- `v1` is the lowercase hex HMAC-SHA256 of the string `<t>.<raw body>`, where `<raw body>` is the request body exactly as received, and the key is the UTF-8 bytes of the signing secret as the API returned it, `whsec_` prefix included.
- During the 24 hours after `:rotate-secret` the header carries two `v1` values, the new secret's first. A delivery is valid when any `v1` matches.


To verify: read the raw body before any JSON parsing, compute the HMAC with each secret you hold, compare in constant time, and reject a delivery whose `t` is more than 5 minutes from your clock. That keeps a captured delivery from being replayed later.

### Test vector

Check your implementation offline with these values. The body is the 182 bytes below, exactly as written, with no whitespace or trailing newline.

|  | Value |
|  --- | --- |
| Secret | `whsec_tOq4fNzJ9kW2xVb8sRmE3yLpH6uCd1aG` |
| `t` | `1792577730` |
| Body | `{"id":"6f1c0d3a-8e2b-4c5d-9a7f-1b3e5d7c9a2f","type":"ping","occurredAt":"2026-10-21T10:15:30.412Z","apiVersion":"v3","data":{"subscriptionId":"3f0a7c9e-5b1d-4e2a-9c8b-7d6e5f4a3b2c"}}` |
| Signed string | `1792577730.` followed by the body |
| Expected `v1` | `c7e17e6eec00632623b5b6d26b01b548f0240a3996292c20ee238fd55bf9ebea` |


Pass `1792577730` as `nowSeconds` or `now` to the samples below so the timestamp check passes.

### Node.js

```js
const crypto = require("crypto");
const express = require("express");

const SECRETS = [process.env.TALLI_WEBHOOK_SECRET]; // add the previous secret during a rotation
if (SECRETS.some((secret) => !secret)) throw new Error("TALLI_WEBHOOK_SECRET is not set");
const TOLERANCE_SECONDS = 300;

function verifySignature(rawBody, header, secrets, nowSeconds = Math.floor(Date.now() / 1000)) {
  const parts = header.split(",").map((part) => part.split("="));
  const timestamp = parts.find(([key]) => key === "t")?.[1];
  const signatures = parts
    .filter(([key, value]) => key === "v1" && /^[0-9a-f]{64}$/.test(value))
    .map(([, value]) => Buffer.from(value, "hex"));
  if (!/^\d+$/.test(timestamp ?? "") || signatures.length === 0) return false;
  if (!(Math.abs(nowSeconds - Number(timestamp)) <= TOLERANCE_SECONDS)) return false;

  const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
  return secrets.some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest();
    return signatures.some((signature) => crypto.timingSafeEqual(signature, expected));
  });
}

const app = express();

app.post("/talli/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Talli-Signature") ?? "";
  if (!verifySignature(req.body, header, SECRETS)) {
    return res.status(401).send("invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // Deduplicate on event.id, apply data only when event.occurredAt is newer than the copy you hold.
  console.log(event.type, event.data.payoutInstructionId, event.data.status);
  res.sendStatus(200);
});

app.listen(3000);
```

`express.raw` keeps the body as the bytes Talli signed. A JSON body parser that runs first re-serializes the body and the signature no longer matches.

### Python

```python
import hashlib
import hmac
import json
import os
import re
import time

from flask import Flask, abort, request

SECRETS = [os.environ["TALLI_WEBHOOK_SECRET"]]  # add the previous secret during a rotation; a missing variable fails here, at startup
TOLERANCE_SECONDS = 300


def verify_signature(raw_body: bytes, header: str, secrets, now=None) -> bool:
    now = now or int(time.time())
    parts = [part.split("=", 1) for part in header.split(",") if "=" in part]
    timestamp = next((value for key, value in parts if key == "t"), None)
    signatures = [value for key, value in parts if key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value)]
    if timestamp is None or not re.fullmatch(r"[0-9]+", timestamp, re.ASCII) or not signatures:
        return False
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    signed_payload = f"{timestamp}.".encode() + raw_body
    for secret in secrets:
        expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(signature, expected) for signature in signatures):
            return True
    return False


app = Flask(__name__)


@app.post("/talli/webhooks")
def talli_webhook():
    header = request.headers.get("Talli-Signature", "")
    if not verify_signature(request.get_data(), header, SECRETS):
        abort(401)

    event = json.loads(request.get_data())
    # Deduplicate on event["id"], apply data only when event["occurredAt"] is newer than the copy you hold.
    print(event["type"], event["data"]["payoutInstructionId"], event["data"]["status"])
    return "", 200
```

`request.get_data()` returns the bytes Talli signed. Do not verify against a re-encoded `request.json`.

### Rotate the secret

```bash
curl --request POST \
  --url 'https://api.talli.ai/v3/distribution-manager/webhooks/YOUR_SUBSCRIPTION_ID/:rotate-secret' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
```

```json
{
  "secret": "whsec_newSecretValue…",
  "previousSecret": "whsec_previousSecretValue…",
  "previousSecretExpiresOn": "2026-10-22T09:12:44.318Z"
}
```

For 24 hours after the rotation every delivery carries two `v1` signatures, so an endpoint that still holds the previous secret keeps verifying. Deploy the new secret within that window. Rotating again inside the window ends the older secret's grace at once. `GET …/webhooks/{id}/secret` returns the current secret, and the previous one while it still signs.

## Retries and the delivery log

An attempt fails when the endpoint answers anything other than `2xx`, does not answer within 10 seconds, cannot be connected to, presents an invalid TLS certificate, or when its URL no longer passes the rules above. Talli then retries on a fixed schedule:

| Attempt | Delay after the previous attempt |
|  --- | --- |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |


Seven attempts in about 20 hours. A delivery is `Pending` until an attempt succeeds, then `Succeeded`; when the seventh attempt fails it is `Failed`. For an endpoint that is down for a day, Talli therefore stops retrying the deliveries that exhausted their schedule in that time; the log keeps them, and `:redeliver` sends them again. Every new event starts its own schedule, so a failing endpoint keeps receiving new events and each exhausts its own seven attempts. Talli never disables a subscription on its own; the log is where you see a failing endpoint.

When a subscription's endpoint stops answering at all, Talli pauses sends to that subscription for a minute after five unanswered attempts in a row. An attempt that falls in the pause is recorded as not sent and spends one of the seven attempts of its delivery, so an outage of a few minutes can cost every pending event more than one attempt.

### Read the log

```bash
curl --request GET \
  --url 'https://api.talli.ai/v3/distribution-manager/webhooks/YOUR_SUBSCRIPTION_ID/deliveries?status=Failed&pageNumber=1&pageSize=20' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
```

The list is newest first and takes `pageNumber` and `pageSize` as every list endpoint. Filters: `status` (`Pending`, `Succeeded` or `Failed`), `eventType` (exact match), `from` and `to` on the delivery's creation time as ISO 8601 with an offset, `from` inclusive and `to` exclusive.

```json
{
  "pageNumber": 1,
  "pageSize": 20,
  "totalItems": 1,
  "data": [
    {
      "id": "b7e4d2c1-9a8f-4e3d-8c7b-6a5f4e3d2c1b",
      "subscriptionId": "3f0a7c9e-5b1d-4e2a-9c8b-7d6e5f4a3b2c",
      "eventId": "6f1c0d3a-8e2b-4c5d-9a7f-1b3e5d7c9a2f",
      "eventType": "payoutInstruction.updated",
      "occurredAt": "2026-10-21T10:15:30.412Z",
      "status": "Failed",
      "attemptCount": 7,
      "nextAttemptAt": null,
      "createdOn": "2026-10-21T10:15:30.901Z",
      "completedOn": "2026-10-22T06:02:11.044Z"
    }
  ]
}
```

`GET …/webhooks/{id}/deliveries/{deliveryId}` adds `data`, the event payload as sent, and `attempts`, oldest first, each with `attemptNumber`, `attemptedOn`, `durationMs`, `responseStatus`, `error` and `responseBodyExcerpt`. An attempt that received no response has `responseStatus` `null` and the cause in `error`.

Both endpoints share a limit of 120 requests per minute per organization. A delivery stays in the log for 30 days after its last attempt.

### Redeliver

```bash
curl --request POST \
  --url 'https://api.talli.ai/v3/distribution-manager/webhooks/YOUR_SUBSCRIPTION_ID/deliveries/YOUR_DELIVERY_ID/:redeliver' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
```

A redelivery is one new attempt of a `Succeeded` or `Failed` delivery: the same event id and body, a fresh signature timestamp, no further retries. The delivery's status becomes the outcome of that attempt. The call returns `202` with the delivery as it was when the call was accepted; read it again to see the new attempt. A `Pending` delivery is rejected with `409` and `errorCode` `WEBHOOK_DELIVERY_PENDING`, with `nextAttemptAt` in the body: an attempt is already scheduled. A disabled subscription is rejected with `409` and `WEBHOOK_SUBSCRIPTION_DISABLED`.

## Sandbox

Webhooks behave on the sandbox exactly as in production: register a subscription on the sandbox base URL with sandbox credentials, and the simulate calls of [Sandbox testing](/docs/get-started/sandbox-testing) produce real deliveries. `:ping` checks the endpoint; a payout instruction created, initiated, claimed and settled on the sandbox sends the `created` and `updated` events your production integration will see. The sandbox has its own subscriptions and signing secrets.