# Payment notifications

When a payment's state or merchant data changes, we POST the new snapshot to your notification URL. The [Payments API](/integrations/payments-api) is the same data, by pull.

We notify on a payment's first indexing, on every accepted processor update that changes anything, and whenever receipt polling advances the state.

## The delivery

```http
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-Yodl-Integration: 0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1
X-Yodl-Signature: t=1785153600,v1=6ced28eb0f662564c0b4e43592bf376caeb042775d4abaa93a008781e1e38071
```

```json
{
  "integrationAddress": "0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1",
  "chainId": "8453",
  "txHash": "0x82aa1e4f552e56f68c92d3cf3fdccbcafb51905c356ba163a4ae84631f416b96",
  "version": 3,
  "state": "success",
  "recipient": {
    "title": "Acme GmbH",
    "subtitle": "Vietcombank 0123"
  },
  "invoice": {
    "amount": "12345",
    "currency": "VND",
    "settlementAmount": "0.48",
    "settlementCurrency": "USD"
  }
}
```

Your endpoint must be HTTPS. Any `2xx` is a success; anything else is recorded as a failure on our side. We give up after 5 seconds.

## Payload

This is a full snapshot, not a patch. Every field is always present, though several are nullable.

| Field | Type | Notes |
| --- | --- | --- |
| `integrationAddress` | string | Lowercased. Matches the `X-Yodl-Integration` header |
| `chainId` | string | Destination chain ID, as a decimal string |
| `txHash` | string | Destination transaction hash |
| `version` | number | The payment's state version. Increments on every state change, never decreases |
| `state` | string | null | One of the states below, or `null` if no state is stored yet |
| `recipient` | object | null | `null` when no merchant name is known |
| `recipient.title` | string | Merchant name |
| `recipient.subtitle` | string | null | Secondary merchant line, e.g. a masked bank account |
| `invoice` | object | null | `null` unless **all four** of its fields are known |
| `invoice.amount` | string | Invoice amount, in `invoice.currency` |
| `invoice.currency` | string | ISO-4217 code |
| `invoice.settlementAmount` | string | What was settled on chain |
| `invoice.settlementCurrency` | string | ISO-4217 code |

`state` is one of:

| State | Meaning |
| --- | --- |
| `pending` | Payment is in flight at the processor |
| `success` | Payment completed |
| `failure` | Payment failed |
| `refund_pending` | A refund is in flight |
| `refund_success` | Refund completed |
| `refund_failure` | Refund failed |

Amounts are strings, not numbers. Do not parse them into a float and back.

## Headers

| Header | Value |
| --- | --- |
| `X-Yodl-Signature` | `t=<unix seconds>,v1=<64 lowercase hex characters>` |
| `X-Yodl-Integration` | The integration address the delivery is addressed to, lowercased |

`X-Yodl-Integration` exists so that an endpoint serving more than one integration can select the right webhook signing secret *before* verifying. It is an unauthenticated routing hint — only a successful signature check proves anything.

`v1` is a **scheme version, not a key version**. Parse `X-Yodl-Signature` as a comma-separated list of `name=value` fields, look up the version you implement, and ignore fields you do not recognise. A future delivery may carry several (`t=…,v1=…,v2=…`) while an algorithm is being rotated. Today only `v1` is emitted.

## The signed string

`v1` is the lowercase hex HMAC-SHA256, keyed on your **webhook signing secret**, over:

```text
signed string = "<t>" + "." + <raw body bytes>
v1            = hex(hmac_sha256(key = <your webhook signing secret>, message = signed string))
```

That is: the value of `t`, a literal `.`, then the request body exactly as it arrived.

* **Read the raw body before any JSON parsing, and sign that.** Re-serializing the parsed object produces different bytes — key order alone is enough. In Express, `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })` keeps the raw bytes; in Next.js route handlers, `await req.text()`.
* **The body is UTF-8.** HMAC the bytes, not a decoded string.

The webhook signing secret is never transmitted. It is only used as an HMAC key, so nothing in the request contains it.

## Verifying a signature

:::code-group
```js [Node]
import { createHmac, timingSafeEqual } from "node:crypto"

const TOLERANCE_SECONDS = 5 * 60

// rawBody must be the exact bytes received: a string or Buffer, never a re-serialized object.
function verifyYodlSignature(header, rawBody, webhookSigningSecret) {
  if (!header) return false

  const fields = new Map(
    header.split(",").map((part) => {
      const i = part.indexOf("=")
      return [part.slice(0, i).trim(), part.slice(i + 1).trim()]
    })
  )

  const timestamp = fields.get("t")
  const signature = fields.get("v1")
  if (!timestamp || !signature) return false

  // Freshness window. Without it, a captured delivery can be replayed at any
  // point in the future and will still verify.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false

  const expected = createHmac("sha256", webhookSigningSecret)
    .update(`${timestamp}.${rawBody}`)
    .digest()
  const received = Buffer.from(signature, "hex")

  // Constant-time comparison. timingSafeEqual throws on a length mismatch,
  // so check the length yourself first.
  // [!code word:timingSafeEqual]
  return received.length === expected.length && timingSafeEqual(expected, received)
}
```

```python [Python]
import hmac
import hashlib
import time

TOLERANCE_SECONDS = 5 * 60


# raw_body must be the exact bytes received, never a re-serialized dict.
def verify_yodl_signature(header: str, raw_body: bytes, webhook_signing_secret: str) -> bool:
    if not header:
        return False

    fields = {}
    for part in header.split(","):
        key, _, value = part.partition("=")
        fields[key.strip()] = value.strip()

    timestamp, signature = fields.get("t"), fields.get("v1")
    if not timestamp or not signature:
        return False

    # Freshness window. Without it, a captured delivery replays forever.
    try:
        age = abs(int(time.time()) - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        webhook_signing_secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    # Constant-time comparison.
    return hmac.compare_digest(expected, signature)
```
:::

:::warning
`expected.toString("hex") === signature` is **not acceptable**. String comparison returns early on the first differing byte, and that timing difference is enough to recover a valid signature byte by byte. Use `crypto.timingSafeEqual`, or your language's equivalent constant-time comparison.
:::

## Worked example

This digest is what our signer produces for this secret, timestamp, and body. It is pinned by a test, so it cannot drift from what we send. Run it through your verifier before you go live.

* **Webhook signing secret:** `integration-example-secret`
* **Timestamp:** `1785153600`
* **Signature:** `6ced28eb0f662564c0b4e43592bf376caeb042775d4abaa93a008781e1e38071`
* **Body**, one line, no trailing newline, no reformatting:

```text
{"integrationAddress":"0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1","chainId":"8453","txHash":"0x82aa1e4f552e56f68c92d3cf3fdccbcafb51905c356ba163a4ae84631f416b96","version":3,"state":"success","recipient":{"title":"Acme GmbH","subtitle":"Vietcombank 0123"},"invoice":{"amount":"12345","currency":"VND","settlementAmount":"0.48","settlementCurrency":"USD"}}
```

The signed string is `1785153600.` followed by that body line verbatim.

:::tip
The timestamp is fixed, so the freshness check in the verifier above will reject this example as stale. When you test against it, pin your clock or test the digest computation on its own — and keep the freshness check in production.
:::

## Handling a delivery

Verify first, then decide whether the snapshot is newer than what you hold. Both gates matter, and in this order.

```mermaid
flowchart TD
    IN[Delivery arrives] --> SIG{"Signature valid and within tolerance?"}
    SIG -->|No| REJ[Reject, do not process]
    SIG -->|Yes| VER{"version newer than stored?"}
    VER -->|No| DROP[Discard: replay or out of order]
    VER -->|Yes| STORE[Store and act on it]
    STORE --> REC["Reconcile against your own records before crediting"]
```

## Two semantics you have to build for

### 1. Current state wins

Deliveries are not ordered. The same payment can arrive twice, and a later state can arrive before an earlier one.

`version` is the payment's state version. It increments on every state change and never decreases. Store it alongside the payment and **discard any delivery whose `version` is not greater than the one you have stored.**

`chainId` + `txHash` identifies the payment; adding `version` identifies a snapshot. Make your handler idempotent on that triple.

### 2. A notification is a claim, not proof of your sale

A payment's integration address is written into its on-chain metadata by whoever builds the payment, so anyone can claim yours.

A delivery proves that some payment claimed your integration address. It does not prove the payment came from your app. Reconcile against your own records before you credit anything, and treat a payment you cannot match as noise.
