> For the complete documentation index, see [llms.txt](https://docs.allscale.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.allscale.io/allscale-checkout/api-reference/api-doc-claim-link-webhook.md).

# API Doc - Claim Link Webhook

**AllScale Open API**\
**Version:** v4\
**Last Updated:** 2026-07-15

***

### Overview

When a **Claim Link** reaches a terminal state **and the corresponding on-chain settlement succeeds**, AllScale POSTs a notification to your configured webhook endpoint. This lets your backend react to a claim, an expiry/refund, or a sender cancellation/refund without polling.

There are exactly **three terminal events**:

| `event`     | Emitted when                                                                                                        |
| ----------- | ------------------------------------------------------------------------------------------------------------------- |
| `claimed`   | The recipient claimed the link **and** the payout transaction was persisted on-chain.                               |
| `expired`   | The link expired unclaimed **and** the refund transaction succeeded on-chain.                                       |
| `cancelled` | The **sender cancelled** (claimed back) a funded, unclaimed link **and** the refund transaction succeeded on-chain. |

> ⚠️ **`cancelled` is new.** Earlier versions of this document stated that there is **no** `cancelled` event — that is **no longer true**. Sender cancel / claim-back is live (see the Cancel (Claim-Back) API), and its refund settles through the same path as an expiry refund, so it emits the same payload shape with `refund_tx_hash` set. **If your handler was written against the old two-event contract, it must now tolerate a third `event` value** — do not treat an unknown event as an error.
>
> **The callback fires after on-chain settlement, not on the status change alone.** A link whose status becomes expired or cancelled does **not** emit its event until the refund succeeds — if a refund is still pending or has failed, no callback is sent (yet). Do not treat "status = cancelled" as equivalent to "a `cancelled` webhook will arrive"; reconcile via the Claim Link status API for the authoritative state.

The claim webhook reuses the **same HMAC-SHA256 request-signing scheme** as every other AllScale webhook — see Webhook Callback Signing & Payload Guide for the canonical-string construction, header definitions, and verification flow. This document covers only what is **specific to the Claim Link webhook**: which links emit it, the payload shape, and the delivery/retry behavior.

***

### Which links emit a webhook

A callback is sent **only** when a webhook URL is configured for the link:

| Link type                                      | Webhook URL source                     | Payload identifier | Events                            |
| ---------------------------------------------- | -------------------------------------- | ------------------ | --------------------------------- |
| **Batch-created links**                        | `webhook_url` on the batch             | `batch_id`         | `claimed`, `expired`              |
| **Merchant single-create (auto-payout) links** | `claim_link_webhook_url` on your store | `store_id`         | `claimed`, `expired`, `cancelled` |
| In-app single links                            | — (never emit)                         | —                  | —                                 |

Exactly **one** of `batch_id` / `store_id` is populated in each payload, identifying how the link was created. The store-level `claim_link_webhook_url` must be an **`https://`** URL (plain `http://` is accepted only in the sandbox environment).

> **`cancelled` only ever carries `store_id`.** A batch sub-link cannot be cancelled individually (its funds are pooled and returned through the batch's aggregate refund flow), so a `cancelled` event is never emitted for a batch link — `batch_id` is always `null` on a `cancelled` payload.

The callback is signed with the **same store credentials** (`api_key` + `api_secret`) that authenticated the create request — i.e. the store that owns the batch or the single-create link.

***

### Webhook Request

#### Method

```http
POST
```

#### Content-Type

```http
application/json
```

#### Required Headers

Identical to all AllScale webhooks (see the signing guide):

| Header              | Description                    |
| ------------------- | ------------------------------ |
| X-API-Key           | Your store's API key           |
| X-Webhook-Id        | Unique webhook ID (dedupe key) |
| X-Webhook-Timestamp | Unix timestamp (seconds)       |
| X-Webhook-Nonce     | Unique per-request nonce       |
| X-Webhook-Signature | `v1=<base64 HMAC-SHA256>`      |

The signature is computed over the canonical string `allscale:webhook:v1\nPOST\n<path>\n<query>\n<webhook_id>\n<timestamp>\n<nonce>\n<sha256_hex(body)>`, signed with your `api_secret`. **Always verify the signature against the raw request-body bytes before parsing the JSON.**

***

## Webhook Payload

The request body is **exactly** the following JSON structure. All money values are **decimal strings in token units** (never floats).

### JSON Field Definitions

| Field            | Type           | Required | Description                                                                                                                                                                                                                                                                                                        |
| ---------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| webhook\_id      | string         | ✅        | Unique webhook ID; must match `X-Webhook-Id`. Use it for idempotency (see below).                                                                                                                                                                                                                                  |
| event            | string         | ✅        | `"claimed"`, `"expired"`, or `"cancelled"`. Tolerate unknown values — new terminal events may be added.                                                                                                                                                                                                            |
| claim\_link\_id  | string         | ✅        | The Claim Link this event is about.                                                                                                                                                                                                                                                                                |
| occurred\_at     | string         | ✅        | Time AllScale **generated this callback**, ISO-8601 UTC with `Z` suffix (e.g. `"2026-07-14T03:21:07Z"`). This is the enqueue time, not the exact on-chain event time — on a retry or catch-up re-delivery it can be later than the actual claim/refund. For the precise on-chain moment, use the transaction hash. |
| amount           | string         | ✅        | Claim/refund amount as a **decimal string** in token units (e.g. `"12.50"`).                                                                                                                                                                                                                                       |
| token\_symbol    | string         | ✅        | Stablecoin symbol, e.g. `"USDT"` / `"USDC"`.                                                                                                                                                                                                                                                                       |
| batch\_id        | string \| null | ➖        | Set for batch-created links; `null` otherwise. Exactly one of `batch_id` / `store_id` is non-null.                                                                                                                                                                                                                 |
| store\_id        | string \| null | ➖        | Set for merchant single-create (auto-payout) links; `null` otherwise.                                                                                                                                                                                                                                              |
| reference\_id    | string \| null | ➖        | Your per-link reference id, if one was supplied at create time; `null` otherwise.                                                                                                                                                                                                                                  |
| claim\_tx\_hash  | string \| null | ➖        | On-chain payout tx hash. Set when `event = "claimed"`; `null` for `expired` / `cancelled`.                                                                                                                                                                                                                         |
| refund\_tx\_hash | string \| null | ➖        | On-chain refund tx hash. Set when `event = "expired"` **or** `"cancelled"` (both settle via a refund); `null` for `claimed`.                                                                                                                                                                                       |

> **Null keys are always present.** Fields that do not apply to a given event/link type are serialized as `null` (not omitted). Your parser should tolerate `null` for every optional field above. The HMAC covers the exact bytes sent, so do not re-serialize before verifying.

### Example — `claimed` (merchant single-create link)

```json
{
  "webhook_id": "a3f8c1e2-...-uuid5",
  "event": "claimed",
  "claim_link_id": "clk_9f2b",
  "occurred_at": "2026-07-14T03:21:07Z",
  "amount": "12.50",
  "token_symbol": "USDT",
  "batch_id": null,
  "store_id": "store_001",
  "reference_id": "order_8899",
  "claim_tx_hash": "0xabc...",
  "refund_tx_hash": null
}
```

### Example — `expired` (batch-created link)

```json
{
  "webhook_id": "b7d4e0a1-...-uuid5",
  "event": "expired",
  "claim_link_id": "clk_1a04",
  "occurred_at": "2026-07-21T00:00:00Z",
  "amount": "12.50",
  "token_symbol": "USDT",
  "batch_id": "batch_2025_q3",
  "store_id": null,
  "reference_id": null,
  "claim_tx_hash": null,
  "refund_tx_hash": "0xdef..."
}
```

### Example — `cancelled` (merchant single-create link)

Emitted after you called the Cancel (Claim-Back) API and the refund settled on-chain. Note `batch_id` is always `null` here, and `refund_tx_hash` is set exactly as for `expired`.

```json
{
  "webhook_id": "c2e9f5b3-...-uuid5",
  "event": "cancelled",
  "claim_link_id": "clk_9f2b",
  "occurred_at": "2026-07-15T09:14:22Z",
  "amount": "12.50",
  "token_symbol": "USDT",
  "batch_id": null,
  "store_id": "store_001",
  "reference_id": "order_8899",
  "claim_tx_hash": null,
  "refund_tx_hash": "0x123..."
}
```

***

### Idempotency & Dedupe

`webhook_id` is **stable and deterministic** for a given `(claim_link_id, event)` pair — every retry and every at-least-once re-delivery of the same event carries the **same** `webhook_id`.

* Treat `webhook_id` as the dedupe key: record processed IDs and ignore repeats.
* Because delivery is **at-least-once**, you **will** occasionally receive the same event more than once (e.g. after a retry or a catch-up sweep). Idempotent handling is required.

***

### Delivery & Retry Behavior

* **At-least-once, best-effort.** The first delivery attempt is synchronous; on any non-`200` response (or timeout / connection error) AllScale schedules **up to 35** exponential-backoff retries.
* **Retry window.** Backoff grows (\~5s, 10s, 20s, 40s, 80s) then caps at 120s per attempt, giving a real delivery window of roughly **30 minutes (with jitter) up to \~1 hour** — enough to survive a brief outage or deploy on your side.
* **No replay after exhaustion.** Once the retries are exhausted the job is dead-lettered and **not** re-delivered by any drain/replay mechanism. If your endpoint is down for longer than the retry window, you will miss that callback — reconcile via the Claim Link status API rather than relying solely on the webhook.
* **Missed-`expired` catch-up.** For **batch** links, AllScale runs a catch-up sweep that re-enqueues `expired` webhooks that were missed within the last **7 days**. This is an additional at-least-once path (hence the dedupe requirement above); it does **not** cover the `claimed` or `cancelled` events, nor store single-create links. Because `cancelled` is store single-create only, it has **no** catch-up path — if your endpoint is down past the retry window you will miss it, so reconcile rather than relying on the callback alone.
* **Request timeout.** Each attempt uses a **5-second** timeout; respond quickly (return `200` and process asynchronously if needed).

***

### Response Expectations

Your endpoint should return:

* `200 OK` — the callback is considered delivered; no retry.
* Any non-`200` — treated as a failure; the callback is retried per the policy above.

***

### Best Practices

✅ Verify `X-Webhook-Signature` against the **raw body bytes** before processing\
✅ Dedupe on `webhook_id` — you will receive duplicates\
✅ Branch on `event` (`claimed` / `expired` / `cancelled`) and read the matching `*_tx_hash` (`claim_tx_hash` for `claimed`, `refund_tx_hash` for `expired` and `cancelled`)\
✅ Treat an **unrecognized** `event` as a no-op you ack with `200`, not as an error — new terminal events may be added (as `cancelled` was)\
✅ Parse `amount` with a Decimal type, not a float\
✅ Return `200` fast; do heavy work asynchronously\
✅ Reconcile against the Claim Link status API for anything longer than the \~1h retry window\
❌ Never log `api_secret` or signatures

***

End of Document
