> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flashnet.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Register webhook endpoints, verify signatures, and handle at-least-once delivery of Orchestra order status events.

Partner webhooks deliver order status changes for your partner account.

Partner webhooks are the primary integration for:

* orchestration orders
* accumulation address deposits (orders have `quoteId = null`)
* liquidation address deposits (orders have `quoteId = null`)

## How do I register an endpoint?

Create an endpoint:

* `POST /v1/webhooks` with `{ "url": "https://..." }`

The response includes:

* `webhookId`
* `secret` (returned once)

## What events are emitted?

Webhook events are derived from public `order.status` transitions:

* `order.processing`
* `order.confirming`
* `order.bridging`
* `order.swapping`
* `order.awaiting_approval`
* `order.refunding`
* `order.delivering`
* `order.completed`
* `order.failed`
* `order.unfulfilled`
* `order.refunded`

Internal `paused` transitions do not emit `order.paused`. Public webhook payloads continue to show `data.status` as `processing` until the order resumes or reaches a partner-visible final state.

See [Order Lifecycle](/products/orchestration/order-lifecycle) for the full state machine, transition rules, and what each status means.

## What does the payload look like?

Webhooks are delivered as an HTTP `POST` with `Content-Type: application/json`.

Payload envelope:

```json theme={null}
{
  "event": "order.refunding",
  "timestamp": "2026-02-04T01:30:47.000Z",
  "data": {
    "id": "ord_...",
    "type": "order",
    "status": "refunding",
    "quoteId": "q_...",
    "amountIn": "250000",
    "amountOut": null,
    "feeBps": 5,
    "feeAmount": "100000",
    "slippageBps": 50,
    "source": {
      "chain": "bitcoin",
      "asset": "BTC",
      "address": null,
      "txHash": "<txid>",
      "sweepTxHash": null
    },
    "destination": {
      "chain": "base",
      "asset": "USDC",
      "address": "0x...",
      "txHash": null
    },
    "depositAddress": "bc1q...",
    "recipientAddress": "0x...",
    "payLinkId": "pl_abc123",
    "payLinkLabel": "October invoice #42",
    "flashnetRequestId": null,
    "sparkTxHash": null,
    "refund": {
      "asset": null,
      "amount": null,
      "txHash": null
    },
    "error": {
      "code": "slippage_exceeded",
      "message": "Pool moved past slippage tolerance between deposit and execution"
    },
    "paymentIntent": {
      "version": 1,
      "amountMode": "exact_out",
      "targetAmountOut": "100000000",
      "requiredAmountIn": "250000",
      "maxAcceptedAmountIn": "250050",
      "inputBufferBps": 2,
      "actualAmountIn": "249900",
      "refundAddress": "bc1q...",
      "exactOutExecution": "strict"
    },
    "feePlan": {
      "version": 1,
      "settlementChain": "solana",
      "settlementAsset": "USDC",
      "appFees": [
        {
          "affiliateId": "flashpartner",
          "recipient": "So1AffiliateOne...",
          "feeBps": 50
        },
        {
          "recipient": "So1AffiliateTwo...",
          "feeBps": 50
        }
      ]
    },
    "feePayouts": {
      "version": 1,
      "entries": [
        {
          "idempotencyKey": "order:ord_...:full:appfee:0",
          "leg": "full",
          "chain": "solana",
          "role": "app_fee",
          "affiliateId": "flashpartner",
          "recipient": "So1AffiliateOne...",
          "feeBps": 50,
          "amount": "24750",
          "platformCutAmount": "4950",
          "recipientAmount": "19800",
          "txHash": "5kW...",
          "recordedAt": "2026-02-04T01:35:00.000Z"
        },
        {
          "idempotencyKey": "order:ord_...:full:payout",
          "leg": "full",
          "chain": "solana",
          "role": "recipient_payout",
          "recipient": "So1RecipientAddress...",
          "feeBps": null,
          "amount": "2475000",
          "platformCutAmount": null,
          "recipientAmount": null,
          "txHash": "3hN...",
          "recordedAt": "2026-02-04T01:35:02.000Z"
        }
      ]
    },
    "createdAt": "2026-02-04T01:30:00.000Z",
    "updatedAt": "2026-02-04T01:30:47.000Z",
    "completedAt": null
  }
}
```

Notes:

* `data` is an order snapshot at the moment the event was emitted.
* `paymentIntent` is included on exact-out orders and on orders created with `amountFiatUsd`. For fiat-denominated orders, `data` also mirrors `amountFiatUsd`, `amountFiatCurrency`, and `spotUsdPerBtc` at the top level.
* `payLinkId` is included when the order originated from a pay-link. `payLinkLabel` is included when that pay-link was created with a `label`. Use either to reconcile webhook events back to the originating pay-link without maintaining an external mapping.
* `zeroconfOffer` is included when a ZeroConf offer has been generated for a Bitcoin L1 deposit. See [ZeroConf](/products/orchestration/zeroconf) for the offer flow and [Offer Fields](/products/orchestration/api/approval-flows#zeroconf-offer-fields) for the field reference.
* `feePlan` is included when quote `appFees` or `affiliateId` was requested.
* `feePayouts` is included once payout legs are recorded.
* `feePayouts.entries[*].role` is `app_fee` or `recipient_payout`.
* `feePayouts.entries[*].affiliateId` is present for registry-based app-fee entries.
* `feePayouts.entries[*].leg` is `full`, `instant`, or `holdback`.
* `feePayouts.entries[*].amount` is the gross fee. `platformCutAmount` is Flashnet's 20% cut. `recipientAmount` is the 80% paid to the fee recipient. These fields are `null` for `recipient_payout` entries.
* When a Flashnet swap has executed, `data.swap` is included with simulation and execution metadata.
* Some fields are `null` until the engine reaches that step.

## What stages appear in the status endpoint?

`GET /v1/orchestration/status` includes a `stages` array. Stages are monotonic markers recorded when a step completes.

Common stage names:

* `deposit_confirmed`
* `amount_reconciled` (actual deposit differed from quoted amount; `amountIn` and `feeAmount` updated)
* `swept` (accumulation/liquidation and other Bitcoin source flows)
* `bridged` (bridge completed)
* `swapped` (swap completed)
* `delivered`
* `refund_requested`
* `refunded`

Bitcoin L1 deposits can also record:

* `zeroconf_offer_pending`
* `zeroconf_offer_accepted`
* `zeroconf_offer_declined`
* `zeroconf_accepted`
* `deposit_claimed`
* `holdback_ready`

Legacy multi-leg stages (`swept_instant`, `swapped_instant`, `bridged_instant`, `delivered_instant`, `swept_holdback`, `swapped_holdback`, `bridged_holdback`, `delivered_holdback`) are deprecated. Current orders use single-leg execution.

Treat stages as informational. The primary state machine is `data.status`.

## How do I verify webhook signatures?

Each delivery includes two headers:

* `X-Flashnet-Signature`: hex-encoded HMAC-SHA256
* `X-Flashnet-Timestamp`: millisecond epoch timestamp of the delivery attempt

The signature is computed as:

* `hex(HMAC_SHA256(secret, timestamp + "." + raw_body_json))`

where `timestamp` is the value of `X-Flashnet-Timestamp`.

Verify the signature against the raw request body bytes, not a re-serialized JSON object. Use the timestamp from the header, not from the payload.

<Warning>
  The signature covers `timestamp.body`, not just `body`. If you are upgrading from a previous integration that verified against the raw body alone, you must update your verification code to prepend the timestamp.
</Warning>

Example (Node):

```ts theme={null}
import crypto from 'node:crypto';

function timingSafeEqualHex(aHex: string, bHex: string): boolean {
  if (!/^[0-9a-f]+$/i.test(aHex) || !/^[0-9a-f]+$/i.test(bHex)) return false;
  if (aHex.length !== bHex.length) return false;

  const a = Buffer.from(aHex, 'hex');
  const b = Buffer.from(bHex, 'hex');
  if (a.length !== b.length) return false;

  return crypto.timingSafeEqual(a, b);
}

export function verifyFlashnetWebhook(params: {
  rawBody: string;
  signatureHeader: string | undefined;
  timestampHeader: string | undefined;
  secret: string;
}): boolean {
  if (!params.signatureHeader || !params.timestampHeader) return false;

  const expected = crypto
    .createHmac('sha256', params.secret)
    .update(`${params.timestampHeader}.${params.rawBody}`)
    .digest('hex');

  return timingSafeEqualHex(expected, params.signatureHeader);
}
```

## How are webhooks delivered?

* At least once: the same event may be delivered multiple times.
* Consider deliveries successful only when your endpoint returns a `2xx` status.

Retries use a fixed backoff schedule:

* 10 seconds
* 30 seconds
* 2 minutes
* 10 minutes
* 30 minutes
* 2 hours
* 6 hours
* 24 hours

After the final retry attempt, the delivery is marked failed.

## What should my handler do?

* Verify the signature before parsing JSON.
* Make processing idempotent.
  * A safe key is `(data.id, event, timestamp)`.
* Return `2xx` only after you have persisted the event.
