> The full uetr.ai documentation index is at https://uetr.ai/llms.txt, and every page under https://uetr.ai/docs/api is also Markdown at its address plus `.md`.

# Webhooks

Instead of asking about each transfer, let uetr.ai send a request to your server when one changes. Webhooks work on every plan.

## Adding an endpoint

Add an endpoint with [Add a webhook endpoint](/docs/api/create-webhook-endpoint): the HTTPS address of your own receiver, or a Slack or Microsoft Teams incoming webhook, which receives a readable message instead of JSON. The answer holds the endpoint's signing secret, shown once. A new endpoint waits for the account owner to confirm it on uetr.ai and, until then, receives only test events. An account can have up to 10 endpoints.

## Events

| Event `type` | Name in `events` | Sent when |
| --- | --- | --- |
| `transfer.status_changed` | `status_changed` | The status the banks report for a transfer changes. |
| `transfer.final_state` | `final_state` | A transfer reaches a final status: credited, rejected, returned or cancelled. |
| `transfer.checks_ended` | `checks_ended` | Automatic checks on a transfer end before it reached a final status. This includes archiving it: the transfer then shows `archived: true`. Deleting a transfer sends no event. |
| `test` | None: always received | You send a test event. It carries the demo transfer. |

When you add the endpoint, choose its transfer events in `events` by their names without `transfer.`, such as `status_changed`; it receives all three unless you choose. Every request is a JSON body with the event's `type`, such as `transfer.status_changed`, its `timestamp` and the transfer as [List transfers](/docs/api/list-transfers) returns it: the full view on a paid transfer, the free view otherwise. A free account's events follow the free checking pace. For example:

```json
{
  "type": "transfer.status_changed",
  "timestamp": "2026-10-14T09:25:00.000Z",
  "data": {
    "transfer": {
      "id": "00000000-0000-4000-8000-000000000000",
      "uetr": "00000000-0000-4000-8000-000000000000",
      "label": "Demo transfer",
      "view": "full",
      "status": "in_transit",
      "status_code": "ACSP/G000",
      "status_explanation": "Passed to the next agent or market infrastructure, with tracking continuing.",
      "checks_finished": false,
      "amount": {
        "value": 12500,
        "currency": "EUR"
      },
      "payment_date": "2026-10-14",
      "bank_reported_amount": {
        "value": 12500,
        "currency": "EUR",
        "payment_date": "2026-10-14"
      },
      "last_checked_at": "2026-10-14T09:20:00.000Z",
      "monitoring_until": "2026-10-14T10:00:00.000Z",
      "check_frequency_minutes": 10,
      "archived": false,
      "added_at": "2026-10-14T09:00:00.000Z"
    }
  }
}
```

and when the transfer is credited:

```json
{
  "type": "transfer.final_state",
  "timestamp": "2026-10-14T09:55:00.000Z",
  "data": {
    "transfer": {
      "id": "00000000-0000-4000-8000-000000000000",
      "uetr": "00000000-0000-4000-8000-000000000000",
      "label": "Demo transfer",
      "view": "full",
      "status": "credited",
      "status_code": "ACCC",
      "status_explanation": "The beneficiary's bank is reporting that it credited the account.",
      "checks_finished": true,
      "amount": {
        "value": 12500,
        "currency": "EUR"
      },
      "payment_date": "2026-10-14",
      "bank_reported_amount": {
        "value": 12500,
        "currency": "EUR",
        "payment_date": "2026-10-14"
      },
      "last_checked_at": "2026-10-14T09:50:00.000Z",
      "monitoring_until": "2026-10-14T10:00:00.000Z",
      "check_frequency_minutes": null,
      "archived": false,
      "added_at": "2026-10-14T09:00:00.000Z"
    }
  }
}
```

## Verifying a request

Every request carries three headers, as [Standard Webhooks](https://www.standardwebhooks.com) defines them:

- `webhook-id`: the event's id. It stays the same when a delivery is tried again, so use it to ignore an event you already handled.
- `webhook-timestamp`: when this attempt was signed, in seconds since 1 January 1970 (UTC).
- `webhook-signature`: `v1,` and a base64 HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<body>`, keyed with the bytes of the secret after `whsec_`. For 24 hours after a [secret rotation](#rotating-the-secret), two signatures arrive, separated by a space; accept the request when either matches.

Verify the body exactly as it arrived, before parsing it, compare signatures in constant time, and refuse a timestamp more than five minutes from your clock. The Standard Webhooks libraries do all of this, for example `standardwebhooks` on npm and on PyPI. Without one, in TypeScript:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(secret: string, headers: Headers, rawBody: string): boolean {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signatures = headers.get("webhook-signature");
  if (!id || !timestamp || !signatures) return false;
  // Refuse a request signed more than five minutes from your clock, either way.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  // During a secret rotation two signatures arrive, separated by a space.
  return signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    const given = Buffer.from(value ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

In Python:

```python
import base64
import hashlib
import hmac
import time


def verify_webhook(secret: str, headers, raw_body: bytes) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not (msg_id and timestamp and signatures) or not timestamp.isdigit():
        return False
    # Refuse a request signed more than five minutes from your clock, either way.
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, content, hashlib.sha256).digest()).decode()
    # During a secret rotation two signatures arrive, separated by a space.
    return any(
        version == "v1" and hmac.compare_digest(value, expected)
        for version, _, value in (entry.partition(",") for entry in signatures.split(" "))
    )
```

To check your code, this secret, id, timestamp and body:

```json
{
  "secret": "whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
  "webhook-id": "3d8a6f0e-5b1c-4c2e-9a7d-2f4b6c8e0a1b",
  "webhook-timestamp": "1792000000",
  "body": "{\"type\":\"test\",\"timestamp\":\"2026-10-14T09:00:00.000Z\",\"data\":{\"transfer\":{}}}"
}
```

give the signature `v1,y+0Tu+rdpUboVRsf4+1REfrj1sdy7ezamCHLSTmHwRM=`.

## Answering, and attempts again

Answer with any 2xx status within 10 seconds. Redirects are not followed and count as a failure. A failed transfer event is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, then once a day, for three days from its first attempt. A test event is tried once and never retried. A 429 or 503 answer's `Retry-After` is respected, up to a day. Events can arrive more than once and out of order, so ignore a `webhook-id` you already handled, and compare the event's `timestamp` and the transfer's status with what you already hold before acting.

## Paused endpoints

An endpoint that has failed for three days is paused, and the account owner is told by email. A Slack address that answers 404 or 410 is paused at once. While an endpoint is paused, only the newest event for each transfer is kept. Once your receiver works again, [send a test event](/docs/api/send-webhook-test): a test that succeeds resumes the endpoint, and the kept events follow.

## Rotating the secret

To replace an endpoint's signing secret, for example after it was exposed, use [Rotate a webhook signing secret](/docs/api/rotate-webhook-secret). The answer holds the new secret, shown once. For the next 24 hours every request carries two signatures, one made with the new secret and one with the previous secret, so your receiver keeps accepting requests while you give it the new secret. After that, only the new secret signs. Rotating again within those 24 hours ends the oldest secret at once.

## Moving an endpoint

To send an endpoint's events to another address, use [Change a webhook endpoint's address](/docs/api/change-webhook-endpoint-url). The endpoint keeps its id, events and signing secret, and waits for the account owner to confirm the new address on uetr.ai, as a new endpoint does: until then it receives only test events. Events still waiting for the old address are dropped. If the owner declines the new address, or nobody confirms it within 7 days, the endpoint is removed. A change counts toward the hourly limit on adding endpoints.

## Delivery log

[List a webhook endpoint's deliveries](/docs/api/list-webhook-deliveries) shows its 50 most recent deliveries, newest first, with each one's attempts and the last status code your receiver answered. Deliveries are kept for 30 days.