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

# Canton Transaction Webhooks

> Canton transactions notify you through the standard TRANSACTION_STATUS_UPDATED webhook. Branch on availableResponses to find offers you can answer.

A Canton integration is a webhook consumer first. An inbound offer is a status update on a transaction you never created, so there is nothing to poll for until the webhook fires.

Fireblocks delivers these to the endpoint you register. You implement the receiver. The envelope is the standard Fireblocks webhook envelope, not a Canton-specific one, and the `data` object is the standard transaction notification. The only Canton-specific thing on the wire is the `cantonDetails` key on `data`.

## Which event fires

Only `TRANSACTION_STATUS_UPDATED` carries `cantonDetails`. A Canton integration can discard every other event type at the front door.

| Field | Type | Description |
| - | - | - |
| `type` | string | Always `TRANSACTION_STATUS_UPDATED`. |
| `tenantId` | uuid | The Fireblocks workspace the notification belongs to. Verify it against your own workspace ID rather than trusting the delivery URL. |
| `timestamp` | int64 | When Fireblocks emitted the notification, in epoch milliseconds. Not a sequence number. |
| `data` | object | The transaction as of this notification. |

## Route an incoming notification

```text theme={"system"}
1. type == TRANSACTION_STATUS_UPDATED
2. read data.cantonDetails
3. no cantonDetails            → not a Canton transaction, ignore
4. cantonDetails.call          → an outgoing call of yours progressed
5. cantonDetails.settlement    → a venue settled an allocation you had locked
6. cantonDetails.offerResponse → offer conversation:
     availableResponses non-empty → answer it
                                    POST /v1/operations/canton/offers/{data.id}/responses
     availableResponses empty     → do not post; follow originalTransactionId if set
```

To answer in step 6, call [Answer an offer](/api-reference/canton-beta/answer-an-offer) with `data.id` as `offerId`. Step 4 covers calls you submitted with [Make a Canton call](/api-reference/canton-beta/make-a-canton-call). Both are covered end to end in [Write Canton Transactions](/docs/write-canton-transactions).

<Warning>
  Step 6 is the answerability rule. Branch on `availableResponses`, never on `subStatus`. Examples 1 and 4 below share `status: CONFIRMING` and `subStatus: PENDING_RECEIVER_APPROVAL`, and only one of them is answerable.
</Warning>

## The five states to tell apart

<Tabs>
  <Tab title="1. Actionable offer">
    An inbound DTCC end-investor onboarding offer. `availableResponses` carries both DTCC values, so this transaction ID is the one to answer.

    ```json theme={"system"}
    {
      "type": "TRANSACTION_STATUS_UPDATED",
      "tenantId": "d41b6f28-9c07-4b13-8e5a-72f0c4a91e36",
      "timestamp": 1788091200000,
      "data": {
        "id": "3f9c1e77-a4b2-4d10-9e88-6c0f1a7b3d52",
        "operation": "CANTON_CALL",
        "status": "CONFIRMING",
        "subStatus": "PENDING_RECEIVER_APPROVAL",
        "assetId": "CANTON",
        "amount": "0",
        "source": { "type": "EXTERNAL" },
        "destination": { "type": "VAULT_ACCOUNT", "id": "12" },
        "cantonDetails": {
          "offerResponse": {
            "version": 1,
            "domain": "ONBOARDING",
            "vendor": "DTCC",
            "availableResponses": [
              "DTCC_END_INVESTOR_ONBOARDING_ACCEPT",
              "DTCC_END_INVESTOR_ONBOARDING_REJECT"
            ]
          }
        }
      }
    }
    ```
  </Tab>

  <Tab title="2. Response in flight">
    The same transaction as example 1, after a response was dispatched. `availableResponses` has emptied and `approvalTransactionId` names the outgoing response transaction. Posting again here returns `409 response-not-available`.

    This is a genuine state, not a race. The offer is neither answerable nor resolved until the response transaction confirms.

    ```json theme={"system"}
    {
      "type": "TRANSACTION_STATUS_UPDATED",
      "tenantId": "d41b6f28-9c07-4b13-8e5a-72f0c4a91e36",
      "timestamp": 1788091260000,
      "data": {
        "id": "3f9c1e77-a4b2-4d10-9e88-6c0f1a7b3d52",
        "operation": "CANTON_CALL",
        "status": "CONFIRMING",
        "subStatus": "APPROVING",
        "assetId": "CANTON",
        "amount": "0",
        "source": { "type": "EXTERNAL" },
        "destination": { "type": "VAULT_ACCOUNT", "id": "12" },
        "cantonDetails": {
          "offerResponse": {
            "version": 1,
            "domain": "ONBOARDING",
            "vendor": "DTCC",
            "availableResponses": [],
            "approvalTransactionId": "5a1d8b0f-2c31-4c7e-9d0a-118e7b6c4a21"
          }
        }
      }
    }
    ```
  </Tab>

  <Tab title="3. Allocation locked">
    The outgoing offer-response transaction created by accepting an allocation, now locked on chain. This `data.id` is what `ALLOCATION_WITHDRAW` takes as `allocationTransactionId`, and this status pair is the only state in which it is accepted.

    The transaction stays here until the venue settles it, moving to `COMPLETED`, or a withdraw succeeds, moving it to `CANCELLED`.

    ```json theme={"system"}
    {
      "type": "TRANSACTION_STATUS_UPDATED",
      "tenantId": "d41b6f28-9c07-4b13-8e5a-72f0c4a91e36",
      "timestamp": 1788094800000,
      "data": {
        "id": "9f2b7c14-8d55-4a02-b6e1-5c3f90a7d488",
        "operation": "CANTON_CALL",
        "status": "CONFIRMING",
        "subStatus": "ALLOCATED",
        "assetId": "CANTON",
        "amount": "1000",
        "source": { "type": "VAULT_ACCOUNT", "id": "12" },
        "destination": { "type": "EXTERNAL" },
        "cantonDetails": {
          "offerResponse": {
            "version": 1,
            "domain": "ALLOCATIONS",
            "availableResponses": [],
            "originalTransactionId": "c9e05a37-4b81-4f22-9d68-1a73e8b0c542",
            "verdict": "ACCEPTED"
          }
        }
      }
    }
    ```
  </Tab>

  <Tab title="4. Linked leg">
    The vault-to-one-time-address transfer case, and the reason the answerability rule exists. One transfer offer flags two transactions with `PENDING_RECEIVER_APPROVAL`. This is the other one.

    It matches example 1 on every field you are tempted to filter on, including `status`, `subStatus`, and `operation`. It differs only in `availableResponses` being empty and `originalTransactionId` being set. Follow that pointer to find where the answer goes.

    This transaction is not broken, not a duplicate, and must not be ignored. It is half of the offer's ledger footprint. It simply is not where the answer goes.

    ```json theme={"system"}
    {
      "type": "TRANSACTION_STATUS_UPDATED",
      "tenantId": "d41b6f28-9c07-4b13-8e5a-72f0c4a91e36",
      "timestamp": 1788098400000,
      "data": {
        "id": "b8f30d51-6a27-4c94-8e15-9d03f7a2c168",
        "operation": "CANTON_CALL",
        "status": "CONFIRMING",
        "subStatus": "PENDING_RECEIVER_APPROVAL",
        "assetId": "CANTON",
        "amount": "250",
        "source": { "type": "VAULT_ACCOUNT", "id": "12" },
        "destination": { "type": "ONE_TIME_ADDRESS" },
        "cantonDetails": {
          "offerResponse": {
            "version": 1,
            "domain": "TRANSFER",
            "availableResponses": [],
            "originalTransactionId": "7c4a2f81-3d95-4e60-b1a7-8f24c6d0e913"
          }
        }
      }
    }
    ```
  </Tab>

  <Tab title="5. Settlement receipt">
    The venue settled the allocation from example 3, and this is the credit side: a real Canton transaction, already `COMPLETED` when it is created, carrying the asset and amount you receive.

    There is nothing to answer and you did not initiate it, so it carries neither `offerResponse` nor `call`. Its `settlement.originalTransactionId` names the allocation it pays for. Without that pointer, this is an unexplained incoming credit.

    ```json theme={"system"}
    {
      "type": "TRANSACTION_STATUS_UPDATED",
      "tenantId": "d41b6f28-9c07-4b13-8e5a-72f0c4a91e36",
      "timestamp": 1788102000000,
      "data": {
        "id": "e2704b96-1f83-4d57-a0c9-6b58e3d1f204",
        "operation": "CANTON_CALL",
        "status": "COMPLETED",
        "assetId": "Amulet",
        "amount": "500",
        "source": { "type": "EXTERNAL" },
        "destination": { "type": "VAULT_ACCOUNT", "id": "12" },
        "cantonDetails": {
          "settlement": {
            "version": 1,
            "domain": "ALLOCATIONS",
            "type": "ALLOCATION_SETTLEMENT",
            "originalTransactionId": "9f2b7c14-8d55-4a02-b6e1-5c3f90a7d488"
          }
        }
      }
    }
    ```
  </Tab>
</Tabs>

## Sub-status reference

| Sub-status | New | What it means |
| - | - | - |
| `PENDING_RECEIVER_APPROVAL` | No | An offer is outstanding. Not a reliable answerability signal. |
| `APPROVING` | Yes | A response is in flight on this offer. `availableResponses` is empty while it is, and a second post returns `409`. |
| `ALLOCATED` | Yes | An allocation is locked on chain and withdrawable. The transaction stays at `CONFIRMING`. |
| `CONFIRMED` | No | The counterparty accepted. |
| `REJECTED_BY_RECEIVER` | No | The counterparty rejected. |
| `RECEIVER_APPROVAL_TIMEOUT` | No | The offer expired unanswered. |

Note that `CONFIRMING` is not transient for Canton. An accepted allocation sits at `CONFIRMING` with `ALLOCATED` until the venue settles it.

## Delivery semantics

Delivery is at-least-once and unordered. Two notifications for the same transaction can arrive out of timestamp order, and `timestamp` is not a sequence number.

Treat every notification as a state snapshot rather than a transition. There is no `previousStatus` field, by design. Re-read `availableResponses` on each notification instead of tracking transitions yourself, and make handling idempotent on `data.id` plus `data.status` plus `data.subStatus`.

Parse permissively. Both the envelope and `data` carry fields beyond this contract, and a strict parser breaks on the next unrelated platform addition.

## What your endpoint should return

| Status | Effect |
| - | - |
| `200` | Acknowledged. Fireblocks does not redeliver. |
| `4XX` | Rejected. Fireblocks redelivers with backoff. |
| `5XX` | Receiver error. Fireblocks redelivers with backoff. |

<Tip>
  Acknowledge first and process asynchronously. Holding the connection open while a handler runs risks a timeout, which is indistinguishable from a failure and triggers a redelivery.
</Tip>

Return `5XX` for a transient failure, because redelivery is what you want. Do not return `4XX` for a payload you will never accept, such as one with no `cantonDetails`, because that becomes a retry loop. Return `200` and drop it instead.

## Reconciling without webhooks

The webhook is the push half of the read side. [`GET /v1/transactions/{txId}`](/api-reference/transactions/get-a-specific-transaction-by-fireblocks-transaction-id) is the pull half, and both carry the identical top-level `cantonDetails` object. Use the pull path at startup, after downtime, or when a delivery fails. See [Read Canton Transactions](/docs/read-canton-transactions).
