> For the complete documentation index, see [llms.txt](https://docs.ur.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ur.app/developer-resources/webhook.md).

# Webhooks

This document is the canonical reference for partner backend engineers integrating with UR's webhook delivery service. It covers the wire protocol, signature verification, retry semantics, idempotency contract, and every event type currently in production.

If you only have a few minutes, read [§1 Overview](#1-overview) and [§4 Signature verification](#4-signature-verification). Everything else is reference material.

***

## 1. Overview

UR pushes asynchronous business events (transactions confirmed on-chain, KYC outcomes, allowance changes, card-authorization failures, partner-KYC session results) to an HTTPS endpoint you register in your partner contract.

The high-level contract:

| Aspect             | Behavior                                                                                         |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| Transport          | `HTTPS POST`, `Content-Type: application/json`                                                   |
| Body               | JSON envelope `{event, data, timestamp}`                                                         |
| Authentication     | EIP-191 ECDSA signature in HTTP headers (V1 legacy + V2 recommended, both delivered in parallel) |
| Delivery semantics | At-least-once, \~48 h retry window, ±20 %-jittered exponential backoff                           |
| Success criterion  | Your endpoint returns HTTP 2xx within the configured timeout (default 30 s)                      |
| Idempotency        | We send `X-Webhook-Request-Id` (UUID v4); partners **MUST** dedupe on this                       |
| Ordering           | Sequential per-partner; across partners is unordered                                             |

> User-Agent: `URBank-Webhook/2.0` (pre-v2.0 partners saw `URBank-Webhook/1.0`). Whitelist by domain, not UA. The `URBank-Webhook` string is a fixed wire-protocol constant retained for backward compatibility; it is not a product name.

***

## 2. Endpoint requirements

Your endpoint **MUST**:

1. Be reachable via HTTPS with a CA-issued certificate (no self-signed).
2. Accept `POST` with `Content-Type: application/json`.
3. Return HTTP 2xx on success within the configured timeout (default 30 s).
4. Return HTTP 4xx on **permanent** failure; we will NOT retry.
5. Return HTTP 5xx (or time out) on **transient** failure; we WILL retry.
6. Be idempotent: handle the same `X-Webhook-Request-Id` more than once gracefully (every retry reuses the same id).

Your endpoint **SHOULD**:

* Verify at least one of the two signatures (see [§4](#4-signature-verification)) before processing.
* Reject any request whose `X-Webhook-Timestamp` skews more than ±5 minutes from your clock (replay protection).
* Ack fast (< 1 s ideal) and process asynchronously. A queue + worker pattern works best because our delivery worker holds a TCP connection open until you respond.

***

## 3. HTTP headers

Every outbound request carries these headers. `X-Webhook-*` are envelope metadata; `X-Api-*` are signature material.

| Header                 | Example              | Meaning                                                                                                        |
| ---------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------- |
| `Content-Type`         | `application/json`   | Always JSON                                                                                                    |
| `User-Agent`           | `URBank-Webhook/2.0` | Stable for the v2.0 generation of the delivery service                                                         |
| `X-Webhook-Request-Id` | `b3e8…-…` (UUID v4)  | **Idempotency key.** Same value across the first delivery and every retry of the same message. Dedupe on this. |
| `X-Webhook-Timestamp`  | `1735689600`         | Unix seconds at the moment the HTTP request is assembled                                                       |
| `X-Webhook-Event-Type` | `transaction`        | Canonical event name (see [§6 Event catalog](#6-event-catalog))                                                |
| `X-Webhook-Attempt`    | `1`, `2`, …          | **1-indexed delivery attempt.** First delivery is `1`, first retry is `2`.                                     |
| `X-Api-Signature`      | `0x…`                | **V1 (legacy) signature.** Present whenever V1 is enabled in your contract.                                    |
| `X-Api-PublicKey`      | `0x…`                | V1 signer Ethereum address. Same address as V2.                                                                |
| `X-Api-Signature-V2`   | `0x…`                | **V2 (recommended) signature.** Present whenever V2 is enabled.                                                |
| `X-Api-PublicKey-V2`   | `0x…`                | V2 signer Ethereum address (= V1 address).                                                                     |

### 3.1 Response headers you can send

| Header                     | When                                                                              | Effect                                                       |
| -------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `X-Webhook-No-Retry: true` | On a 4xx or 5xx response when you've decided this message should never be retried | We move the message to `DEAD` immediately and stop retrying. |

Everything else in your response is ignored. Status code is what we key on (see [§5](#5-retry-policy)).

***

## 4. Signature verification

All webhooks are signed with the **EIP-191** "personal sign" scheme used by Ethereum wallets:

```
prefix  = "\x19Ethereum Signed Message:\n" + len(message)
hash    = Keccak256(prefix + message)
sig     = ECDSA-secp256k1(server_private_key, hash)   // 65 bytes, 0x-prefixed hex
recover = ecrecover(hash, sig)                        // → server address
```

The signing **key** is the same for V1 and V2; only the signed **message** differs.

### 4.1 V1 (legacy)

```
message = body                                         // raw JSON bytes, byte-for-byte
header  = X-Api-Signature: 0x{65-byte hex}
```

V1 is what legacy partners have always verified. It will remain on the wire indefinitely; a retirement announcement will run on a ≥3-month deprecation window if we ever do retire it.

### 4.2 V2 (recommended)

```
message = timestamp + "." + request_id + "." + body
        = "1735689600.b3e8ffff-0000-4000-8000-000000000000.{...json...}"
header  = X-Api-Signature-V2: 0x{65-byte hex}
```

V2 binds the timestamp and request id into the signature. Without that binding an attacker who captures a webhook can replay the body with a fresh timestamp; with V2 the recovered address won't match. **All new integrations should validate V2.**

### 4.3 Address pinning

The signer address is published in your partner contract. Pin it on your side; do **not** trust whatever `X-Api-PublicKey(-V2)` says without comparing to the pinned value. The header is a convenience; the contract is the source of truth.

### 4.4 Reference implementation (Node.js)

The verifier call moved namespace between ethers major versions. Both forms are shown below; the rest of the function is identical.

**ethers v6** (current; published 2023+):

```js
const { verifyMessage } = require("ethers");

function verifyV2(req, expectedSigner) {
  const ts   = req.headers["x-webhook-timestamp"];
  const id   = req.headers["x-webhook-request-id"];
  const body = req.rawBody.toString("utf8");          // raw, before JSON.parse
  const sig  = req.headers["x-api-signature-v2"];

  const message = `${ts}.${id}.${body}`;
  const recovered = verifyMessage(message, sig);      // v6: top-level export
  return recovered.toLowerCase() === expectedSigner.toLowerCase();
}
```

**ethers v5** (legacy; for partners on the v5 line):

```js
const { ethers } = require("ethers");

function verifyV2(req, expectedSigner) {
  // ... ts / id / body / sig / message identical to v6 above ...
  const recovered = ethers.utils.verifyMessage(message, sig);   // v5: under .utils
  return recovered.toLowerCase() === expectedSigner.toLowerCase();
}
```

If you're not sure which version you're on, run `node -p "require('ethers').version"`. `6.x.x` is v6; `5.x.x` is v5. The hash + secp256k1 recovery underneath is the same EIP-191 algorithm in both versions, so the recovered address is byte-identical.

### 4.5 Reference implementation (Go)

```go
import (
    "fmt"
    "strings"
    "github.com/ethereum/go-ethereum/common"
    "github.com/ethereum/go-ethereum/crypto"
)

func verifyV2(ts, reqID, body, sigHex, expectedAddr string) (bool, error) {
    msg      := fmt.Sprintf("%s.%s.%s", ts, reqID, body)
    prefixed := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(msg), msg)
    hash     := crypto.Keccak256Hash([]byte(prefixed))

    sig := common.FromHex(sigHex)
    if sig[64] >= 27 { sig[64] -= 27 }
    pub, err := crypto.SigToPub(hash.Bytes(), sig)
    if err != nil {
        return false, err
    }
    return strings.EqualFold(crypto.PubkeyToAddress(*pub).Hex(), expectedAddr), nil
}
```

### 4.6 Verifying both versions during the migration window

A partner who verifies V2 only and rejects on failure will break the day UR rotates a key (rare but planned). Defense-in-depth:

```
if  X-Api-Signature-V2 present:  verify V2 → 200 if OK
elif X-Api-Signature   present:  verify V1 → 200 if OK
else:                            401 missing signature
```

A common partner-side bug is parsing JSON first and then re-serializing for signature verification. That breaks because Go's `encoding/json` does **not** preserve key order. **Read the raw request body byte-for-byte before deserializing.** The `req.rawBody` / `c.Request.GetBody()` pattern in most frameworks does this for you.

***

## 5. Retry policy

### 5.1 Classification by response

| Outcome                                         | Class                                                            |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| HTTP 2xx                                        | Success (message marked `DELIVERED`).                            |
| HTTP 4xx (except 408, 429)                      | Non-retryable (message marked `DEAD`).                           |
| HTTP 408, 429                                   | Retryable (server-side hint that you were rate-limited or slow). |
| HTTP 5xx                                        | Retryable.                                                       |
| Network error / timeout / DNS failure           | Retryable.                                                       |
| HTTP response header `X-Webhook-No-Retry: true` | Treated as non-retryable regardless of status.                   |

### 5.2 Backoff schedule

±20 % jittered exponential backoff, base 30 s, cap 6 h, max **20 attempts** over roughly a **48-hour** window:

| Attempt | Approx. delay since previous |
| ------- | ---------------------------- |
| 1       | (initial)                    |
| 2       | \~30 s                       |
| 3       | \~1 min                      |
| 4       | \~2 min                      |
| 5       | \~4 min                      |
| …       | doubling each step, jittered |
| 11      | \~4 h                        |
| 12      | \~6 h (cap)                  |
| 13 … 20 | \~6 h each                   |

After attempt 20 the message is moved to `DEAD` and persisted for 90 days. UR operations can manually re-queue a `DEAD` message on partner request.

### 5.3 Circuit breaker

If we observe 20 consecutive failures (5xx / timeout / connection-refused) for a given partner, a per-partner circuit opens for 60 s. Open-circuit attempts are not sent to your endpoint; they're requeued for the next backoff slot. Your retry count is **not** incremented while the circuit is open.

### 5.4 What `X-Webhook-Attempt` tells you

The header is 1-indexed:

* `Attempt: 1`: first delivery (fresh message)
* `Attempt: 2+`: retry

You can use this to bias logging (verbose on `1`, terse on retries) or to detect partner-side hot loops where retries keep failing for the same root cause.

***

## 6. Event catalog

Subscription is **per-partner, per-event-type**. You only receive event types your contract subscribes to. Asking for an event you're not subscribed to is the most common silent-fail in integrations; if you don't see events you expect, check your subscription before debugging anything else.

New event types may be added; existing event-type bodies are **append-only**. We may add new optional fields, never rename or remove existing ones.

### 6.0 Field-naming quirks (read this first)

The customer identifier appears under one of two field names depending on event type, and its JSON type also varies between events. These are **wire-fixed** across all delivered events; partners must handle both shapes.

| Event types                                                                                                                              | Field name | JSON type                 |
| ---------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------- |
| `transaction`, `transaction_v2`, `card_spending_failed`                                                                                  | `urId`     | string                    |
| `allowance`                                                                                                                              | `urId`     | int64 (number, no quotes) |
| `fma.account.result`, `fma.penny_drop.result`, `fma.kyc.reuse_check.result`, `fma.kyc.result` (and the planned `fma.kyc.retry_required`) | `urId`     | int64 (number, no quotes) |
| `kyc_status`, `sumsub_kyc_result`, `monthly_limit_exceeded`                                                                              | `tokenId`  | string                    |

All four shapes refer to the **same** UR NFT token id. The split is legacy: the `tokenId` family predates the unification on `urId`, and the int64/string split predates the unification on string. The wire shapes are frozen for backwards compatibility; do not assume future events will pick one canonical form.

Other quirks worth flagging up-front:

* **Subscription changes** (enable / disable an event type, rotate webhook URL) are coordinated with your UR technical liaison; there is no self-service API.
* **Token symbols** referenced in event bodies (`USD24`, `EUR24`, `CHF24`, `JPY24`, `CNH24`, `SGD24`, `HKD24`, `USDC`) are the UR-issued tokenized fiat assets; see [Tokenized deposits](/concepts/tokenized-deposits.md) for the catalogue and contract addresses.
* **Wire-fixed JSON keys** such as `deadLine` (capital `L`, in `card_spending_failed`) are preserved verbatim from legacy schemas. Where they appear, the field reference table for that event notes the quirk explicitly.

### 6.1 Money movement & on-chain events

#### `transaction` (legacy transaction event)

Emitted when a tracked transaction is confirmed on-chain (deposit, withdrawal, transfer, FX, card spend).

```json
{
  "event": "transaction",
  "timestamp": 1735689600,
  "data": {
    "urId": "1000001234",
    "title": "Sent USD24",
    "subtitle": "to 0xAbC…",
    "amount": "100.00",
    "type": "transfer_out",
    "timestamp": 1735689599,
    "currency": "USD24",
    "direction": "out",
    "txHash": "0xdead…beef",
    "chainId": "eip155:5000",
    "inputToken": "USD24",
    "inputAmount": "100.00",
    "inputTokenAddress": "0x…"
  }
}
```

Bidirectional fan-out: for a P2P transfer between two of your customers (both bound to your `partner_id`) you receive **two** webhooks: one with `direction=out` and the sender's `urId`, and one with `direction=in` and the receiver's `urId`. For a self-transfer (same `urId` on both sides) business-key dedup collapses to a single webhook.

Field reference (see also [TransactionData in the API reference](https://docs.ur.app/api-reference/account/delegated-contract-mode#transactiondata)):

| Field                                                          | Type                | Description                                                            |
| -------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------- |
| `urId`                                                         | string              | Token ID of the customer this event belongs to                         |
| `title`, `subtitle`, `image`                                   | string              | UI strings; partner can ignore or display                              |
| `amount`, `currency`                                           | string              | Display amount (decimal string) and ticker; partner-visible            |
| `type`                                                         | string              | Business type (e.g. `transfer_in`, `transfer_out`, `card_spend`, `fx`) |
| `direction`                                                    | string              | `in` or `out` from this customer's perspective                         |
| `txHash`, `chainId`                                            | string              | On-chain identifiers; `chainId` is CAIP-2 (e.g. `eip155:5000`)         |
| `inputToken`, `inputAmount`, `inputTokenAddress`               | string              | Source asset                                                           |
| `outputAmount`                                                 | string (omitempty)  | FX leg                                                                 |
| `mcc`                                                          | uint64 (omitempty)  | Merchant Category Code (card spend)                                    |
| `reference`, `bankAccount`, `crdMultiToken`, `partnerRefId`, … | various (omitempty) | Channel-specific extras                                                |

#### `transaction_v2` (current transaction event)

Same business event as `transaction`, but a normalized schema and lifecycle (`broadcastTimeE9`, `finalTimeE9`). New partners should subscribe to `transaction_v2` and ignore `transaction`.

```json
{
  "event": "transaction_v2",
  "timestamp": 1735689600,
  "data": {
    "id": 42,
    "txHash": "0xdead…beef",
    "txLogIndex": 0,
    "blockNumber": 12345678,
    "createTimeE9": 1735689500000000000,
    "broadcastTimeE9": 1735689510000000000,
    "finalTimeE9": 1735689600000000000,
    "type": "transfer_out",
    "chainId": "eip155:5000",
    "chainName": "Mantle",
    "urId": "1000001234",
    "direction": "out",
    "amount": "100.00",
    "currency": "USD24",
    "status": "CONFIRMED",
    "detailsJson": "{…}",
    "reqId": "…",
    "refundType": ""
  }
}
```

| Field        | Type   | Description                                                                                                                                                                                                                                                                                                                               |
| ------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `refundType` | string | Refund discriminator. `""` (not a refund), `BANK_REFUND` (bank transfer refund), `CARD_REFUND` (card spending refund), or `CARD_REVERSAL` (card authorization reversal). See [§12.4 Identifying refund transactions](https://docs.ur.app/api-reference/account/managed-custody-mode#id-12.4-identifying-refund-transactions) for details. |

`detailsJson` is a stringified JSON blob whose structure depends on `data.type`. See [detailsJson reference by transaction type](https://docs.ur.app/api-reference/account/managed-custody-mode#id-12.3-detailsjson-reference-by-transaction-type) for the full schema of each type.

#### `allowance` (ERC-20 allowance change)

Emitted when one of your customer's allowance flags flips (approve / revoke / transferFrom drains it).

```json
{
  "event": "allowance",
  "timestamp": 1735689600,
  "data": {
    "urId": 1000001234,
    "allowances": [
      { "tokenSymbol": "USD24", "hasAllowance": true,  "chainId": "eip155:5000" },
      { "tokenSymbol": "EUR24", "hasAllowance": false, "chainId": "eip155:5000" }
    ]
  }
}
```

| Field               | Type   | Description                                                                  |
| ------------------- | ------ | ---------------------------------------------------------------------------- |
| `data.urId`         | int64  | Customer UR id (note: int64 here, not string; legacy quirk)                  |
| `data.allowances[]` | array  | Updated allowance rows                                                       |
| `…tokenSymbol`      | string | One of `USD24`, `EUR24`, `CHF24`, `JPY24`, `CNH24`, `SGD24`, `HKD24`, `USDC` |
| `…hasAllowance`     | bool   | True iff on-chain allowance > 0 for the UR contracts                         |
| `…chainId`          | string | CAIP-2 chain id                                                              |

Only the **rows that changed** are present; never a full snapshot. Treat `allowances` as a patch, not a state dump.

#### `card_spending_failed` (card authorization rejected for user-caused reason)

Emitted when a customer's card spend fails because of insufficient balance / missing allowance / similar customer-actionable reason. Not for network or processor failures.

```json
{
  "event": "card_spending_failed",
  "timestamp": 1735689600,
  "data": {
    "urId": "1000001234",
    "status": "FAILED",
    "reason": "INSUFFICIENT_ALLOWANCE",
    "transactionCurrency": "EUR",
    "transactionAmount": "42.00",
    "cardCurrency": "EUR",
    "deadLine": "2025-12-31T23:59:59Z",
    "authorizationToken": "…",
    "txHash": "0x…",
    "transactionTime": "2025-12-30T10:15:30Z"
  }
}
```

| Field                                      | Type               | Description                                                                                                                                                                                                 |
| ------------------------------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `urId`                                     | string             | Customer UR id                                                                                                                                                                                              |
| `status`                                   | string             | Always `FAILED` for this event                                                                                                                                                                              |
| `reason`                                   | string (omitempty) | Machine-readable reason code                                                                                                                                                                                |
| `transactionCurrency`, `transactionAmount` | string (omitempty) | Original authorization details                                                                                                                                                                              |
| `cardCurrency`                             | string (omitempty) | Card's billing currency                                                                                                                                                                                     |
| `deadLine`                                 | string (omitempty) | Customer-action deadline (ISO 8601). **Wire-fixed**: the JSON key is literally `deadLine` (capital `L`), not the more conventional `deadline`. Preserved verbatim for backwards-compat and will not change. |
| `authorizationToken`                       | string (omitempty) | Reference for the rejected authorization                                                                                                                                                                    |

#### `monthly_limit_exceeded` (monthly spending limit hit)

Emitted when a customer's monthly spend in CHF would cross their configured limit.

```json
{
  "event": "monthly_limit_exceeded",
  "timestamp": 1735689600,
  "data": {
    "tokenId": "1000001234",
    "txAmount": "150.00",
    "txCurrency": "EUR",
    "availableCHF": "0.00",
    "usedCHF": "10000.00",
    "maxCHF": "10000.00"
  }
}
```

| Field                               | Type   | Description                           |
| ----------------------------------- | ------ | ------------------------------------- |
| `tokenId`                           | string | Customer UR id                        |
| `txAmount`, `txCurrency`            | string | Transaction that triggered the breach |
| `availableCHF`, `usedCHF`, `maxCHF` | string | Limit accounting in CHF               |

### 6.2 KYC (generic)

#### `kyc_status` (KYC status change)

Emitted when UR returns an updated compliance status for a customer.

```json
{
  "event": "kyc_status",
  "timestamp": 1735689600,
  "data": {
    "tokenId": "1000001234",
    "status": "Pass"
  }
}
```

| Field     | Type   | Description                                                    |
| --------- | ------ | -------------------------------------------------------------- |
| `tokenId` | string | Customer UR id                                                 |
| `status`  | string | One of: `Pending`, `Pass`, `Rejected`, `ManualReview`, `Error` |

#### `sumsub_kyc_result` (Sumsub review result)

Emitted when a Sumsub KYC review completes for one of your customers.

```json
{
  "event": "sumsub_kyc_result",
  "timestamp": 1735689600,
  "data": {
    "tokenId": "1000001234",
    "kycResult": "Pass"
  }
}
```

| Field       | Type   | Description          |
| ----------- | ------ | -------------------- |
| `tokenId`   | string | Customer UR id       |
| `kycResult` | string | `Pass` or `Rejected` |

### 6.3 Partner-managed (FMA) KYC

These events fire only for partners using the **Fiat-Mediated Account (FMA)** integration model: partners who own the KYC user-experience and let UR run downstream compliance. They are **not** sent to non-FMA partners.

The FMA events share a common envelope shape (per-event exceptions are noted in each entry):

* `urId` (int64): UR token id (issued by UR-side mint after `create-account`)
* `sessionId` (string, UUID): the partner-KYC session this event belongs to (present on `fma.account.result`, `fma.penny_drop.result`, `fma.kyc.reuse_check.result`; **absent** on `fma.kyc.result`)
* `partnerId` (string): your partner id (same string you sign requests with)
* `status` (string): a state discriminator inside one logical event family
* `occurredAt` (int64): Unix seconds at emission

#### `fma.account.result` (account onboarding outcome)

Single event that subsumes "account activated" and "session rejected" outcomes. Branch on `status`:

| `status`    | Meaning                                                                                                 | Extra fields                 |
| ----------- | ------------------------------------------------------------------------------------------------------- | ---------------------------- |
| `activated` | KYC passed end-to-end. Customer is now Live on-chain with their CHF spending limit set.                 | (none)                       |
| `rejected`  | The session ended in failure (compliance hard-rejected, KYC-rejected, draft expired, or retry expired). | `rejectCode`, `rejectReason` |

```json
{
  "event": "fma.account.result",
  "timestamp": 1735689600,
  "data": {
    "urId": 1000001234,
    "sessionId": "b3e8ffff-0000-4000-8000-000000000000",
    "partnerId": "partner_example",
    "status": "activated",
    "occurredAt": 1735689600
  }
}
```

```json
{
  "event": "fma.account.result",
  "timestamp": 1735689700,
  "data": {
    "urId": 1000001234,
    "sessionId": "b3e8ffff-0000-4000-8000-000000000000",
    "partnerId": "partner_example",
    "status": "rejected",
    "rejectCode": "KYC_REJECTED",
    "rejectReason": "KYC status=REJECTED (checkCount=3)",
    "occurredAt": 1735689700
  }
}
```

`rejectCode` enumeration (the values you will see in production, grouped by what caused the rejection):

| `rejectCode`               | Meaning                                                                                                                                    | Origin          |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `KYC_REJECTED`             | Compliance review returned a hard reject. Partner should NOT retry.                                                                        | KYC pipeline    |
| `UPSTREAM_RETRY_EXHAUSTED` | We could not reach an upstream (Sumsub or the compliance backend, etc.) within the 48-hour retry budget. Partner may request ops re-queue. | KYC pipeline    |
| `INTERNAL_ERROR`           | Defensive catch-all for a bug on our side. Always paired with an incident on UR's side; contact ops.                                       | KYC pipeline    |
| `SUMSUB_REJECTED`          | Sumsub returned a hard reject during identity verification.                                                                                | KYC pipeline    |
| `FORMA_NOT_SIGNED`         | Customer never signed Form A within the session lifetime.                                                                                  | KYC pipeline    |
| `SESSION_EXPIRED`          | Draft session expired (never made it past the data-ingestion step within its TTL).                                                         | Maintenance job |
| `PENNY_TIMEOUT`            | Session reached `IdentityVerification` but neither penny-drop nor NFC completed within 30 days.                                            | Maintenance job |
| `STALE_ORPHAN_GC`          | Garbage-collection of orphaned legacy session rows; should not appear for newly-onboarded partners.                                        | Maintenance job |

Additional `Reject*` codes from the API-level error catalogue may appear if a partner-side API call triggers a session termination (rare). Treat any unknown code as "session ended; surface `rejectReason` to your support tooling and ask UR ops for translation". The set above covers >99 % of real production rejections.

When you receive `status=activated` you can trust:

* The customer's UR NFT is **Live** on-chain
* Their `setClientLimit` transaction has confirmed
* It is safe to allow card spends / withdrawals / etc.

#### `fma.penny_drop.result` (penny-drop verification)

Per-attempt verification result for the penny-drop flow (applies to partners whose KYC variant requires a micro-transfer match step; subscription is per partner). Branch on `status`:

| `status`   | Meaning                                                                                                                                                                                                     |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `verified` | The customer's micro-transfer matched our expected amount; KYC advances to `IdentityVerification → SignFormA`.                                                                                              |
| `failed`   | The most recent attempt did not match. The customer can retry up to a configured cap. When the last allowed attempt fails, the payload includes `exhausted: true` and no further attempts will be accepted. |

```json
{
  "event": "fma.penny_drop.result",
  "timestamp": 1735689600,
  "data": {
    "urId": 1000001234,
    "sessionId": "b3e8ffff-…",
    "partnerId": "partner_example",
    "status": "verified",
    "amount": { "value": "1.23", "currency": "EUR" },
    "remitterCountryISO3": "CHE",
    "transactionRecordId": 987654,
    "txHash": "0xabc…def",
    "txLogIndex": 0,
    "occurredAt": 1735689600
  }
}
```

```json
{
  "event": "fma.penny_drop.result",
  "timestamp": 1735689700,
  "data": {
    "urId": 1000001234,
    "sessionId": "b3e8ffff-…",
    "partnerId": "partner_example",
    "status": "failed",
    "amount": { "value": "0.50", "currency": "EUR" },
    "remitterCountryISO3": "CHE",
    "failReason": "AMOUNT_BELOW_MIN",
    "attemptsRemaining": 0,
    "exhausted": true,
    "transactionRecordId": 987655,
    "txHash": "0xabc…f01",
    "txLogIndex": 0,
    "occurredAt": 1735689700
  }
}
```

Field reference (every field below is present on every delivery of this event type unless marked optional):

| Field                 | Type                                       | Present when         | Description                                                                                                                                                                                                                                                                                                      |
| --------------------- | ------------------------------------------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `urId`                | int64                                      | always               | UR token id                                                                                                                                                                                                                                                                                                      |
| `sessionId`           | string (UUID)                              | always               | Partner-KYC session id                                                                                                                                                                                                                                                                                           |
| `partnerId`           | string                                     | always               | Your partner id                                                                                                                                                                                                                                                                                                  |
| `status`              | string                                     | always               | `verified` or `failed`                                                                                                                                                                                                                                                                                           |
| `amount`              | object `{value: string, currency: string}` | always               | The micro-transfer amount we observed (decimal string + 3-letter ISO currency)                                                                                                                                                                                                                                   |
| `remitterCountryISO3` | string                                     | always               | The remitter person's country, ISO 3166-1 alpha-3. Used by partner UI for compliance display.                                                                                                                                                                                                                    |
| `failReason`          | string                                     | `status=failed` only | Machine-readable reason. Common values: `AMOUNT_MISMATCH`, `AMOUNT_BELOW_MIN`, `CURRENCY_MISMATCH`, `IBAN_MISMATCH`. Treat unknown values as "failed; ask UR ops for translation".                                                                                                                               |
| `attemptsRemaining`   | int                                        | `status=failed` only | How many additional attempts the customer has after this one. `0` means no more attempts will be accepted by `/kyc/penny-transfer`.                                                                                                                                                                              |
| `exhausted`           | bool                                       | `status=failed` only | `true` iff `attemptsRemaining=0`. When `true`, the customer must switch to the NFC fallback path; the penny-drop endpoint will return `PENNY_DROP_MAX_ATTEMPTS_EXCEEDED` for any further calls. The session itself is **not** terminated; an `fma.account.result` may still follow after the NFC path completes. |
| `transactionRecordId` | int64                                      | always               | `ur_transaction_records.id` of the evaluated on-chain fiat-deposit mint. Stable identity for the deposit this result refers to.                                                                                                                                                                                  |
| `txHash`              | string                                     | always               | On-chain tx hash of the evaluated fiat-deposit mint.                                                                                                                                                                                                                                                             |
| `txLogIndex`          | int64                                      | optional             | Event ordinal inside the tx; disambiguates when a single tx hash carries multiple mint events. Omitted when not applicable.                                                                                                                                                                                      |
| `occurredAt`          | int64                                      | always               | Unix seconds at emission                                                                                                                                                                                                                                                                                         |

#### `fma.kyc.reuse_check.result` (shared-token handoff verdict)

Fires only for partners using the **shared-token (Copy Applicant) KYC reuse** model. It is the async mirror of a handoff attempt's conclusive verdict: emitted once per conclusive attempt for `status ∈ {passed, incomplete}`. **Terminal (non-remediable) verdicts are deliberately NOT webhooked**; the partner learns those from the synchronous handoff response / `POST /api/fma/v1/kyc/check` and UR ops. A lost delivery degrades to polling `/kyc/check`, so this event is fire-and-forget on our side.

```json
{
  "event": "fma.kyc.reuse_check.result",
  "timestamp": 1735689600,
  "data": {
    "urId": 1000001234,
    "partnerId": "partner_example",
    "sessionId": "b3e8ffff-0000-4000-8000-000000000000",
    "attempt": 1,
    "status": "incomplete",
    "correlationId": "…",
    "missingFields": ["identity.document", "liveness"],
    "requiredLevels": ["id-and-liveness"],
    "occurredAt": 1735689600
  }
}
```

| Field            | Type          | Present when             | Description                                                                                                     |
| ---------------- | ------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `urId`           | int64         | always                   | UR token id                                                                                                     |
| `partnerId`      | string        | always                   | Your partner id                                                                                                 |
| `sessionId`      | string (UUID) | always                   | The handoff session this verdict belongs to                                                                     |
| `attempt`        | int           | always                   | Session-scoped monotonic attempt counter (part of the business key, so redeliveries of the same attempt dedupe) |
| `status`         | string        | always                   | `passed` (import accepted) or `incomplete` (remediable; resubmit the missing data and re-handoff)               |
| `correlationId`  | string        | always                   | Trace id for cross-referencing with UR ops                                                                      |
| `missingFields`  | string\[]     | `status=incomplete` only | Dot-path identifiers of the data still required                                                                 |
| `requiredLevels` | string\[]     | optional                 | Cloned-workflow level names mapped from `missingFields`, when resolvable                                        |
| `occurredAt`     | int64         | always                   | Unix seconds at emission                                                                                        |

#### `fma.kyc.result` (Sumsub review outcome for shared-token flows)

Fires for shared-token / pull / SDK FMA flows when the underlying Sumsub review reaches an outcome. This is the **Sumsub-layer KYC verdict**, distinct from `fma.account.result` (which is the account-*activation* outcome). Branch on `status`.

| `status`   | Meaning                                                                              |
| ---------- | ------------------------------------------------------------------------------------ |
| `pass`     | Identity data accepted; the flow has advanced past all Sumsub levels to `SignFormA`. |
| `rejected` | Sumsub returned a hard reject (`RED`).                                               |
| `step_up`  | Additional documents requested. (Reserved; not emitted by this path yet.)            |

```json
{
  "event": "fma.kyc.result",
  "timestamp": 1735689600,
  "data": {
    "urId": 1000001234,
    "partnerId": "partner_example",
    "status": "rejected",
    "rejectCode": "SUMSUB_REJECTED",
    "rejectReason": "…",
    "occurredAt": 1735689600
  }
}
```

| Field          | Type   | Present when           | Description                               |
| -------------- | ------ | ---------------------- | ----------------------------------------- |
| `urId`         | int64  | always                 | UR token id                               |
| `partnerId`    | string | always                 | Your partner id                           |
| `status`       | string | always                 | `pass`, `rejected`, or `step_up`          |
| `rejectCode`   | string | `status=rejected` only | Machine-readable reject reason            |
| `rejectReason` | string | optional               | Free-form reason text for support tooling |
| `occurredAt`   | int64  | always                 | Unix seconds at emission                  |

> Note: `fma.kyc.result` carries no `sessionId`; correlate on `urId` + `occurredAt`.

#### `fma.kyc.retry_required` (ops triggered a partner-driven retry): **PLANNED, not yet emitted**

> ⚠️ **This event is not dispatched in production today.** The name is reserved and the payload below is the *designed* contract (Phase B / pull-mode retry). The current codebase does **not** emit it; do not build a hard dependency on receiving it. Partners are notified of session outcomes via `fma.account.result`; when Phase B ships this event will carry the retry details below.

Will be emitted when UR operations decide that a compliance-rejected (or otherwise incomplete) session can be unblocked by the partner resubmitting specific fields. The original session is `Canceled`; a new session is created and its id is in `retrySessionId`.

```json
{
  "event": "fma.kyc.retry_required",
  "timestamp": 1735689600,
  "data": {
    "urId": 1000001234,
    "partnerId": "partner_example",
    "externalUserId": "partner-side-user-id-xyz",
    "retryLevel": 1,
    "retryLevelCode": "INSUFFICIENT_DATA",
    "retryReason": "address proof unreadable",
    "requiredFields": ["address.proofDocument"],
    "requiredActions": ["resubmit-address-proof"],
    "flowId": 4567,
    "flowVersion": 2,
    "deadlineAt": 1736294400,
    "createdAt": 1735689600,
    "retrySessionId": "c0ffee00-0000-4000-8000-000000000000",
    "retryOfSessionId": "b3e8ffff-0000-4000-8000-000000000000"
  }
}
```

Field reference:

| Field              | Type          | Description                                                                                                                                                                                                                                                                                                                               |
| ------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `urId`             | int64         | UR token id (unchanged between original and retry sessions)                                                                                                                                                                                                                                                                               |
| `partnerId`        | string        | Your partner id                                                                                                                                                                                                                                                                                                                           |
| `externalUserId`   | string        | The partner-side user id you bound to this `urId` at `create-account` time                                                                                                                                                                                                                                                                |
| `retryLevel`       | int           | Numeric severity / scope hint (`0` = light, `1` = field-level re-submit, `2` = full re-onboarding). Treat as advisory; the authoritative drivers are `requiredFields` and `requiredActions`.                                                                                                                                              |
| `retryLevelCode`   | string        | Machine-readable retry reason. **Not constrained by an enum on our side**; the operator chooses the string when triggering the retry. Treat unknown values as opaque and rely on `retryReason` / `requiredFields` / `requiredActions` for routing. Examples seen in production: `INSUFFICIENT_DATA`, `EXPIRED_DOC`, `LOW_QUALITY_SELFIE`. |
| `retryReason`      | string        | Free-form reason text suitable for partner-side support tooling (not for direct end-user display)                                                                                                                                                                                                                                         |
| `requiredFields`   | string\[]     | Dot-path field identifiers that must be re-submitted (e.g. `"address.proofDocument"`, `"identity.documentBack"`). Empty array means "no specific field; resubmit the whole session".                                                                                                                                                      |
| `requiredActions`  | string\[]     | High-level actions the partner must perform (e.g. `"resubmit-address-proof"`, `"reupload-selfie"`). Parallel to `requiredFields`; use whichever your UI codepath maps to.                                                                                                                                                                 |
| `flowId`           | int64         | Internal flow row id (opaque to partner; useful when filing tickets with UR ops)                                                                                                                                                                                                                                                          |
| `flowVersion`      | int           | Bumped on each retry of the same logical onboarding; lets ops correlate sequences                                                                                                                                                                                                                                                         |
| `deadlineAt`       | int64         | Unix seconds. After this time the new retry session expires and a fresh `fma.account.result` with `status=rejected` + `rejectCode=SESSION_EXPIRED` will fire.                                                                                                                                                                             |
| `createdAt`        | int64         | Unix seconds when the retry was triggered                                                                                                                                                                                                                                                                                                 |
| `retrySessionId`   | string (UUID) | **The new sessionId.** All subsequent partner KYC API calls (`/kyc/permit`, `/kyc/sign-form`, etc.) MUST use this id, not the original.                                                                                                                                                                                                   |
| `retryOfSessionId` | string (UUID) | The original (now `Canceled`) sessionId; useful for correlating your records                                                                                                                                                                                                                                                              |

Action on receipt: ask the customer to resubmit the listed `requiredFields` / `requiredActions` against `retrySessionId` before `deadlineAt`. The original `retryOfSessionId` is `Canceled` and any further API calls against it will return `SESSION_NOT_FOUND`.

***

## 7. Idempotency and ordering

### 7.1 What to dedupe on

**Dedupe on `X-Webhook-Request-Id`.** That UUID is fixed for the lifetime of a message: every retry sends the same id, and our database uniqueness constraint on `(event_type, partner_id, business_key)` guarantees the same logical event never gets two request ids.

Concretely on your side:

```
on_receive(req):
  if already_processed(req.headers["X-Webhook-Request-Id"]):
    return 200 OK
  verify_signature(req); if !ok: return 401
  process(req.body)
  mark_processed(req.headers["X-Webhook-Request-Id"], ttl=7d)
  return 200 OK
```

### 7.2 What NOT to dedupe on

* The HTTP body, or any subset of fields inside it. Two different business events (e.g. status `activated` and a later status `rejected` for the same session id after a retry flow) can share field values, and you do want to receive both.
* `X-Webhook-Timestamp`: that changes on every retry.

### 7.3 Ordering guarantees

* **Per-partner first-delivery order**: the **first delivery attempts** for messages belonging to one partner go out in the same order they were produced. The internal Kafka topic is partitioned by `partner_id` and a single consumer goroutine per partition processes strictly sequentially, advancing the Kafka offset as soon as each delivery attempt completes (whether success or failure).
* **Retries are out-of-band**: a failed message moves to `PENDING_RETRY` and is re-published to the same partition by a separate retry dispatcher after its backoff. The consumer **does not block on retries**; it proceeds to the next fresh message immediately. As a consequence, **a retry of message A may arrive after a later message B** if B's first delivery happens to succeed while A is still cycling. Build your handler so that re-applying message A after message B's effects is safe.
* **Across partners**: no ordering guarantee.
* **Within an FMA session**: the `fma.account.result` event for a `sessionId` is the **final word** for that session; once you've seen it, no further events for the same `sessionId` will arrive. Earlier `fma.penny_drop.result` events for the same `sessionId` are produced first, but the retry caveat above still applies (a delayed retry could theoretically arrive after `account.result`). If you order on the partner side by `occurredAt`, you'll get the intended chronology even on a delayed retry.

***

## 8. Operations

### 8.1 Rate limits

Per-partner default: 20 requests per second, burst 40. If you can sustain higher and need it, raise it with your UR technical liaison.

### 8.2 IP allowlists

Our outbound IP is stable per Kubernetes deployment but may shift on node rotation. **Allowlist by domain, not by IP.** If you must allowlist by IP, ask for the current allocation in writing and treat it as advisory; we won't notify before rotations.

### 8.3 TLS

Standard CA chain. We don't pin certificates on our side; you don't need to share anything for handshake.

### 8.4 Recovery probing

During incident response our system may re-drive delivery to your endpoint, but it does **not** send a synthetic probe event; there is no `__probe__` event type. Recovery reuses the actual business messages (replayed from the internal Kafka topic), so anything you receive is a real, dedupable event carrying its normal `X-Webhook-Request-Id`. Keep handling every delivery idempotently (see §7); there is no special probe body to special-case.

### 8.5 Cancellation

UR operations can cancel a `PENDING_RETRY` message via internal admin tooling (for example, if a refund / reversal makes an earlier event obsolete). Messages already in flight may still arrive at your endpoint for a few seconds after cancellation. **Treat late arrivals idempotently**; your business-side reconciliation should already handle this.

***

## 9. Troubleshooting

| Symptom                                                   | Likely cause                                                                                                   | Fix                                                                                            |
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Signature verification fails on every webhook             | You're re-serializing the body after JSON-parse                                                                | Read `req.rawBody` (or framework equivalent); sign over raw bytes, not re-encoded              |
| Some webhooks verify, others don't                        | Two different keys delivered (V1 vs V2 with different message composition); you're using one verifier for both | Implement both verifiers (see [§4.6](#46-verifying-both-versions-during-the-migration-window)) |
| Webhooks for one customer suddenly stop                   | Your endpoint is returning non-2xx and we hit the 20-attempt cap → `DEAD`                                      | Check your logs at `X-Webhook-Request-Id`; ask UR ops to re-queue                              |
| Webhooks for ALL customers stop briefly, then resume      | Circuit breaker opened on our side after 20 consecutive failures from your endpoint                            | Look at the 60 s before resumption for the failure mode                                        |
| Same `X-Webhook-Request-Id` arrives 5× in a minute        | Your endpoint is timing out (we keep retrying) or returning 5xx                                                | Acknowledge fast; process asynchronously                                                       |
| Webhook count is roughly half what you expected           | Subscription not enabled for the missing event type                                                            | Check your partner contract subscriptions                                                      |
| FMA `activated` arrives but customer is not Live on-chain | Should never happen; `activated` is only fired **after** the on-chain status flip and limit-set both confirm   | Capture the `X-Webhook-Request-Id` and `sessionId`, contact UR ops                             |

For unresolved incidents, open a ticket via your UR technical liaison with at minimum:

* `X-Webhook-Request-Id`
* `X-Webhook-Timestamp`
* `X-Webhook-Event-Type`
* The full request body (raw bytes)
* The HTTP status you returned and the response body

***

## 10. Quick reference card

```
POST {your endpoint}
Host: {your domain}
Content-Type: application/json
User-Agent: URBank-Webhook/2.0
X-Webhook-Request-Id:  b3e8ffff-0000-4000-8000-000000000000
X-Webhook-Timestamp:   1735689600
X-Webhook-Event-Type:  transaction_v2
X-Webhook-Attempt:     1
X-Api-Signature:       0x…                                  (V1, optional in V2-only mode)
X-Api-PublicKey:       0xServerAddress
X-Api-Signature-V2:    0x…                                  (V2, recommended)
X-Api-PublicKey-V2:    0xServerAddress

{"event":"transaction_v2","data":{…},"timestamp":1735689600}
```

Verification message for V2:

```
"1735689600.b3e8ffff-0000-4000-8000-000000000000.{"event":"transaction_v2","data":{…},"timestamp":1735689600}"
```

Return `200 OK` to ack. Return `4xx` to fail permanently. Return `5xx` / time out to retry.

***

## See also

* [Signature and verify](https://docs.ur.app/api-reference/signature-and-verify): generic UR request-signing rules and the signature scheme for the **partner → UR** direction. Note: that doc currently documents only the V1 webhook signature (UR → partner direction); the V2 scheme defined in [§4.2](#42-v2-recommended) of this page is the authoritative webhook reference. The EIP-191 primitive is shared across both directions, but the message composition differs (request-body + deadline for partner → UR; timestamp + request-id + body for V2 webhooks).
* [OpenAPIs](https://docs.ur.app/api-reference): full reference for the partner REST API; `TransactionData` and friends are documented there.
* [Partner integration flow concepts](/welcome/partner-integration-flow-concepts.md): when each event fires in the customer lifecycle.
* [The user lifecycle](/concepts/overview-the-user-lifecycle.md): end-to-end customer journey, with webhook touchpoints.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ur.app/developer-resources/webhook.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
