All pages

Developer docs

Webhooks

View as Markdown

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: 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 typeName in eventsSent when
transfer.status_changedstatus_changedThe status the banks report for a transfer changes.
transfer.final_statefinal_stateA transfer reaches a final status: credited, rejected, returned or cancelled.
transfer.checks_endedchecks_endedAutomatic 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.
testNone: always receivedYou 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 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:

{
  "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:

{
  "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 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, 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:

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:

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:

{
  "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: 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. 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. 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 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.