Webhooks & Notifications
Real-time push notifications delivered to your endpoint over HTTP, organized into three streams. Subscribe per vault to receive events as they happen.
Webhook Streams
| Stream | Scope | What It Covers |
|---|---|---|
| Outgoing | Per vault | Full lifecycle of transactions created within the platform |
| Blockchain | Per vault | Every on-chain transaction affecting a vault address (outgoing and external), as observed on-chain |
| Incoming | Per vault | Deposits to vault addresses — from mempool detection through confirmations and rollbacks |
A single webhook URL can serve any subset of the three streams.
Delivery Model
Understanding how notifications are delivered is essential to building a correct consumer.
Envelope
Every delivered webhook is a JSON object with two envelope fields plus the event-specific body:
| Field | Description |
|---|---|
id | Unique notification ID. Stable across re-deliveries of the same notification — use it for idempotency. |
timestamp | Delivery timestamp, Unix epoch milliseconds. |
stream | "outgoing", "blockchain", or "incoming". |
type | Event type within the stream (see each stream below). |
| … | Remaining fields depend on stream + type. |
Guarantees
- At-least-once. A notification may be delivered more than once (retries, redeliveries after platform recovery). Make your handler idempotent — deduplicate on
id, or on the natural key(blockchainHash, type, confirmations). - Per-vault ordering. Notifications for one vault are delivered sequentially in the order they were produced; if a delivery fails, the platform retries it before sending any later one for the same vault, so you won't receive a later event while an earlier one is still undelivered. There is no ordering guarantee across different vaults. Two caveats still make strict ordering unsafe to rely on: at-least-once means a notification (even an earlier
mined) can be redelivered after a later one, and arollback(chain reorg) legitimately arrives after theconfirmations for the same hash. Make handlers idempotent (dedupe onid, or on(blockchainHash, type, confirmations)) and tolerant of a later event superseding an earlier one. - Success = HTTP 2xx. Any
2xxresponse marks the notification delivered. Any non-2xx, or a transport error (timeout, DNS, refused), counts as a failure and is retried. - Timeout. Each delivery attempt has a 10-second timeout. Respond
2xxquickly and do heavy processing asynchronously.
Retries & backoff
Failed notifications stay queued and are retried automatically with linear backoff (the retry delay grows with the number of consecutive failures). A persistently unreachable endpoint accumulates a backlog that keeps being retried until your endpoint recovers.
After you fix a broken endpoint, the backlog is delivered to your current URL, but the accumulated backoff still gates how soon retries resume. To resume promptly, call the retry-schedule nudge. Updating the subscription URL alone does not reset the backoff.
Security of the destination
Outbound deliveries are plain HTTP POST. The platform does not sign payloads (no HMAC) and does not authenticate to your endpoint beyond POSTing to the exact URL you configured. Protect your webhook receiver yourself:
- Use an unguessable URL (include a long random path/secret) or terminate mTLS / an auth proxy in front of it.
- Do not trust a webhook as the sole source of truth for funds movement — reconcile against the transactions API and the blockchain-tx list before crediting a customer.
Managing Subscriptions
Authorization
| Action | Required permissions |
|---|---|
POST /{vaultId}, PUT /{vaultId}/confirmations, DELETE /{vaultId}, PATCH /{vaultId}/notifications/retry-schedule, replay | manageNotifications on the vault and manageVaults on the organization |
GET /{vaultId}, GET /{vaultId}/confirmations, GET /notifications, replay (read paths) | Above or global Vault:read |
Without the required permissions the call returns 403.
Set up subscriptions
POST /api/v1/subscriptions/{vaultId} — configure which streams a vault delivers, and to which URL:
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhookUrl": "https://your-app.com/webhooks/carabaas",
"streams": ["outgoing", "incoming", "blockchain"]
}' \
https://api.carabaas.com/api/v1/subscriptions/{vaultId}
Returns 200 with the created subscription rows.
POST replaces the vault's entire subscription set with the streams in the body. The webhookUrl applies to every stream in the call.
- To remove one stream, POST the full remaining list without it.
- To change the URL, POST the same streams with the new URL — subsequent deliveries, including any queued backlog, go to the new URL.
- To stop everything, use
DELETE.
Get subscription info
curl -H "Authorization: Bearer $TOKEN" \
https://api.carabaas.com/api/v1/subscriptions/{vaultId}
Delete subscriptions
Removes all subscriptions for the vault:
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
https://api.carabaas.com/api/v1/subscriptions/{vaultId}
Confirmation settings
Each network publishes a fixed set of confirmation depths at which it emits confirmation events (its confirmations array — for example [1, 6, 12]: a shallow "first block" depth, an intermediate depth, and a deep-confirmation depth). By default a vault receives events at all of them.
PUT /api/v1/subscriptions/{vaultId}/confirmations sets, per network, the maximum depth you want notifications for — you receive every published depth up to and including your value, and deeper ones are suppressed. For a network publishing [1, 6, 12]: setting 6 delivers the depth-1 and depth-6 events but drops the deep 12; setting 12 (already the maximum) changes nothing.
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"confirmations": {
"ethereum-mainnet": 6,
"bitcoin-mainnet": 3
}
}' \
https://api.carabaas.com/api/v1/subscriptions/{vaultId}/confirmations
The value must be one of the depths that network actually publishes — look them up in the confirmations array from GET /networks. Submitting any other number (e.g. 5 for a network publishing [1, 6, 12]) is rejected with 400:
Confirmation number for network ethereum-mainnet must be one of 1, 6, 12
An unknown network key also returns 400. The call replaces the whole map for the vault — it does not patch a single network.
Get confirmation settings
curl -H "Authorization: Bearer $TOKEN" \
https://api.carabaas.com/api/v1/subscriptions/{vaultId}/confirmations
Returns { "confirmations": { [network]: maxDepth, … } }. An empty object means no override — you receive every depth the network publishes.
Recover from backoff
PATCH /api/v1/subscriptions/{vaultId}/notifications/retry-schedule — after fixing a broken endpoint, pull the vault's next retry forward so the queued backlog is attempted promptly instead of waiting out the accumulated backoff:
curl -X PATCH -H "Authorization: Bearer $TOKEN" \
https://api.carabaas.com/api/v1/subscriptions/{vaultId}/notifications/retry-schedule
Returns { "nextTryAt": <timestamp | null> }. null means the vault is already eligible for retry. This is pull-forward only — it never delays an already-imminent retry, and it is a no-op if the vault has no backlog.
Outgoing Stream
Tracks the full lifecycle of transactions created within the platform. Each meaningful state transition emits a notification.
Transaction lifecycle
Progress events (happy path)
| Type | Extra fields | Description |
|---|---|---|
approval-pending | quorum, approvals | Quorum-gated transaction created; waiting for M-of-N approvals. Emitted at create time. Not sent for no-quorum orders. |
approved | quorum, approvals | One approval recorded (emitted once per approval step until quorum is met). |
pending | quorum, approvals | No-quorum order accepted for signing (status becomes requested). Not emitted for quorum-gated orders. May arrive near requested — both are informational. |
requested | — | Transaction queued for signing / blockchain attempt created. |
inited | — | Blockchain transaction built. |
to-sign ‡ | — | Signing payloads prepared; awaiting your signatures. External-signer vaults only. |
signed | — | Signature complete. |
submitted | blockchainHash | Broadcast to the blockchain. |
mined | blockchainHash, balanceChanges, metadata | Confirmed on-chain, with balance changes. |
‡ External signer — to-sign is emitted only for vaults configured with an external signer, where signing is not performed by the platform. It signals that the prepared signing payloads are ready for you to sign and submit back via the API; the transaction advances to signed once your signatures are accepted. Vaults signed by the platform never emit to-sign.
Vaults with master-approvals enabled use GET /api/v1/transactions/{txId}/master-approval-payload for the final signing bundle — there are no separate master-approval-* webhook types. Track them via approved, to-sign, and the standard progress events above.
Intermediate progress events are informational — not every transaction emits all of them, and delivery order is not guaranteed.
Drive your state machine from:
- Terminal success:
mined - Terminal failure:
failed,cancelled,replaced,rolledback - External signer action:
to-sign→ sign via API - Transient pause:
warning(status unchanged; onlyinsufficient_balanceandhosted_singner_unavailableare delivered as warnings)
Use submitted/mined as the primary on-chain progress pair. Poll GET /transactions/{id} or GET .../events when you need reasonDetails on insufficient_balance warnings.
Exception & failure events
| Type | Extra fields | Description |
|---|---|---|
warning | reasonCode | Processing paused for a transient reason; the transaction is still alive and retried automatically. Status is unchanged. Carries reasonCode only — no reasonDetails (fetch GET .../transactions/{id}/events for the breakdown on insufficient_balance). |
cancelled | reasonCode | Terminal. Cancelled — see reasonCode for the cause. Client declines end here with declined_by_client (there is no separate declined webhook). |
failed | blockchainHash | Terminal. Failed on-chain (e.g. reverted) or during processing. |
rolledback | blockchainHash | Terminal. Rolled back after submission (chain reorg). |
replaced | blockchainHash | Terminal. Superseded by a replacement transaction that mined (e.g. RBF). |
Warning reasonCodes you'll receive:
reasonCode | Meaning |
|---|---|
insufficient_balance | Source can't cover amount + fee at signing time. Top up the address — the platform picks it back up automatically. |
hosted_singner_unavailable | A hosted signer is temporarily unavailable (our side). Resolves automatically. |
Other operational conditions (generic signer issues, network maintenance, and similar) do not surface as warning webhooks. If a paused condition never resolves within the platform's retry window, the transaction ends in cancelled with a matching reasonCode.
Cancellation reasonCodes: insufficient_balance, singner_unavailable, hosted_singner_unavailable, managed_singner_unavailable, shared_singner_unavailable, network_maintenance, not_enough_utxos, order_expired, invalid_order, transaction_expired, declined_by_client, rejected_by_approver, rejected_by_master_approver, replacement_status_invalid, hash_already_exists.
Common fields
Every outgoing notification carries the envelope (id, timestamp, stream: "outgoing", type) plus:
| Field | Description |
|---|---|
vaultId | Vault the transaction belongs to. |
transactionId | Platform transaction ID. |
orderId | Your idempotency key for the order. |
network | Blockchain network. |
accountId | Account within the vault (when the tx was scoped to an account/address). |
addressId | Source address (when the tx was scoped to a specific address). |
destinationAddress | Recipient blockchain address. |
memo | Destination memo/tag, when set (Stellar, Ripple, Cosmos). |
metadata | Network-specific metadata (gas, gasPrice, fee, …). |
blockchainHash | On-chain hash — present from submitted onward. |
reasonCode | Present on warning, cancelled. |
balanceChanges | Present on mined — see Understanding balanceChanges. |
quorum, approvals | Approval state — present on pending / approval-pending / approved. |
flowId | Set when the transaction is part of an approval flow. |
Enriched context (in balanceChanges)
Each entry in a mined notification's balanceChanges carries the full account/address context, so you can attribute movement without extra API calls:
| Field | Description |
|---|---|
account.name | Account name (e.g. hot-wallets). |
account.data | Custom metadata you set on the account. |
address.name | Address name (e.g. customer-12345). |
address.networkAddress | On-chain address string. |
address.hdpath | HD derivation path. |
address.data | Custom metadata you set on the address. |
address.memo | Memo/tag value (Stellar, Ripple, Cosmos). |
On mined, entries are collapsed to the level the transaction was created at. Address-level entries carry both address and account; account-level entries carry only account; vault-level entries carry neither. See outgoing aggregation.
Example — mined
{
"id": "b0f5a1e2-8c3d-4e6f-9a1b-2c3d4e5f6a7b",
"timestamp": 1758633270000,
"stream": "outgoing",
"type": "mined",
"vaultId": "cai3GvhueQApTaCcUtjai9",
"network": "ethereum-sepolia",
"accountId": "eFjwUQXB8CMnrTHSgYzaL6",
"addressId": "gMP71sR5sNUnGdKFTsNzp6",
"destinationAddress": "0x3f06F88431d30067a7E87BB87957d008ADCc3bA7",
"orderId": "59850710-3998-4347-8c82-0d625bac9bbb",
"transactionId": "suoPF5JPqgo2NvMmMfR6mg",
"blockchainHash": "0x6a925762547de72487bb1cce0c95226e8e9cf24d...",
"metadata": {
"gas": "21000",
"gasUsed": "21000",
"gasPrice": "1200010",
"isContractCall": false
},
"balanceChanges": [
{
"asset": "c1",
"amount": "-14000000000000000",
"amountFormatted": {
"asset": { "name": "ETH Sepolia", "decimals": 18, "type": "native" },
"value": "-0.014"
},
"tags": [],
"address": {
"id": "gMP71sR5sNUnGdKFTsNzp6",
"networkAddress": "0xF3fC7B157615c5eD88f87d05f24C4C8E0c8D6A42",
"network": "@eth-like",
"hdpath": "m/44/60/0/0/1",
"name": "hot-wallet:eth-like",
"data": { "purpose": "operations" }
},
"account": { "id": "eFjwUQXB8CMnrTHSgYzaL6", "name": "hot-wallets", "data": {} }
},
{
"asset": "c1",
"amount": "-21000000000000",
"amountFormatted": {
"asset": { "name": "ETH Sepolia", "decimals": 18, "type": "native" },
"value": "-0.000021"
},
"tags": ["fee"],
"address": { "id": "gMP71sR5sNUnGdKFTsNzp6", "networkAddress": "0xF3fC7B157615c5eD88f87d05f24C4C8E0c8D6A42", "network": "@eth-like", "hdpath": "m/44/60/0/0/1", "name": "hot-wallet:eth-like", "data": {} },
"account": { "id": "eFjwUQXB8CMnrTHSgYzaL6", "name": "hot-wallets", "data": {} }
}
]
}
Example — warning
{
"id": "d7c1f2a3-4b5c-4d6e-8f90-1a2b3c4d5e6f",
"timestamp": 1758633267120,
"stream": "outgoing",
"type": "warning",
"reasonCode": "insufficient_balance",
"orderId": "77566f66-634e-4c0f-adce-2adc0bce7dd7",
"transactionId": "ooRPCgGMpeB1a13HG1Ajyj",
"network": "ethereum-sepolia",
"vaultId": "4inhhkumdUQAuvNnM77qQs",
"accountId": "43ta6eUh3qmWFZVmMCfNP1",
"addressId": "sd3hr3UQLFX5zUwPXqSk3o",
"destinationAddress": "0xe8d5a90ba593ad8fbc7d40de4dfc8b7f269f9cb2"
}
Blockchain Stream
Reports every on-chain transaction affecting a vault address — both your outgoing transactions and external third-party transfers — exactly as they appear on-chain. Scoped per vault. This is the detailed, per-entry view; balance changes are not aggregated here (see aggregation per stream).
Event types
| Type | Extra fields | Description |
|---|---|---|
transaction | balanceChanges, metadata | A confirmed on-chain transaction touching a vault address. |
mempool | balanceChanges, metadata | Transaction detected in the mempool (chains that expose one, e.g. Bitcoin). |
confirmation | confirmations | Confirmation-count update for a tracked transaction (no balance changes). |
rollback | — | A previously reported transaction was rolled back (chain reorg). |
Common fields
Envelope (id, timestamp, stream: "blockchain", type) plus:
| Field | Description |
|---|---|
vaultId | Vault whose addresses are affected. |
network | Blockchain network. |
blockchainHash | On-chain transaction hash. |
confirmations | Current confirmation count (on confirmation; may appear on others). |
balanceChanges | Per-address, un-aggregated changes — present on transaction / mempool only. |
metadata | Network-specific metadata — present on transaction / mempool. |
transactionId | Set when the hash links to a platform transaction. |
Incoming Stream
Tracks deposits to vault addresses, from mempool detection through confirmations and potential rollbacks.
Event types
| Type | Description |
|---|---|
mempool | Deposit detected in the mempool before block inclusion. |
confirmation | Deposit confirmed, with the current confirmation count. |
rollback | A previously seen deposit was rolled back after a reorg. |
Common fields
Envelope (id, timestamp, stream: "incoming", type) plus:
| Field | Description |
|---|---|
vaultId | Vault receiving the deposit. |
accountId | Account within the vault. |
addressId | Receiving address. |
network | Blockchain network. |
blockchainHash | On-chain transaction hash. |
transactionId | Platform transaction ID assigned to the deposit. |
orderId | Auto-assigned order ID for the deposit. |
confirmations | Current confirmation count. |
balanceChanges | Aggregated balance changes for the receiving address — see below. |
memo | Memo/tag the deposit was routed with (Stellar, Ripple, Cosmos). |
reference | Deposit reference, when present. |
metadata | Network-specific metadata (gas, contract call, …). |
Example — deposit confirmation
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"timestamp": 1758633000000,
"stream": "incoming",
"type": "confirmation",
"network": "ethereum-sepolia",
"blockchainHash": "0xc2cc27bdcee3e369a05fe740da898de8b8ace653...",
"vaultId": "cai3GvhueQApTaCcUtjai9",
"transactionId": "d6ZktNHf45cVdHTufyrwU2",
"orderId": "29270015-c058-40be-9303-003502234cf8",
"accountId": "eFjwUQXB8CMnrTHSgYzaL6",
"addressId": "gMP71sR5sNUnGdKFTsNzp6",
"confirmations": 1,
"metadata": { "gas": "21000", "gasUsed": "21000", "gasPrice": "1200015", "isContractCall": false },
"balanceChanges": [
{
"asset": "c1",
"amount": "49999974799685000",
"amountFormatted": {
"asset": { "name": "ETH Sepolia", "decimals": 18, "type": "native" },
"value": "0.049999974799685"
},
"tags": [],
"address": {
"id": "gMP71sR5sNUnGdKFTsNzp6",
"networkAddress": "0xF3fC7B157615c5eD88f87d05f24C4C8E0c8D6A42",
"network": "@eth-like",
"hdpath": "m/44/60/0/0/1",
"name": "customer-12345",
"data": { "mid": "cust-12345" }
},
"account": { "id": "eFjwUQXB8CMnrTHSgYzaL6", "name": "deposits", "data": {} }
}
]
}
Understanding balanceChanges
balanceChanges is an array describing how balances moved, per address + asset. It appears on mined (outgoing), on incoming (mempool/confirmation/rollback), and on blockchain transaction/mempool.
Entry shape
| Field | Description |
|---|---|
asset | Universal Asset ID. |
amount | Integer string in the asset's smallest unit. Negative = debit, positive = credit. Never has a decimal point. |
amountFormatted | Human-readable value for recognized assets: { asset: {...}, value } (decimal string, signed). Omitted for unrecognized assets. |
tags | Zero or more classification tags — see Tags. |
address | Enriched address object (id, networkAddress, name, data, hdpath, memo). Absent on vault-level (merged) entries. |
account | Enriched account object (id, name, data). Absent on vault-level (merged) entries. |
memoInfo | Sub-account attribution for a registered-memo credit, when applicable. |
Tags
Tags label what a change is. A single entry carries the tags that apply to it (usually one, sometimes none).
| Tag | Meaning |
|---|---|
fee | Network fee, delivered as its own entry. On outgoing/mined you always receive a separate fee entry alongside the transfer, including on UTXO chains (see UTXO). |
change | A UTXO change output returned to you. Where it surfaces depends on where it lands — see UTXO. |
rebate | A client-relevant credit (always positive). Kept as its own entry — never folded into the transfer amount. |
mint | A protocol-issued credit on the fee asset that the chain hands the sender as a side effect (e.g. NEO N3 auto-claims GAS, VeChain VTHO). Kept as its own entry; never nets against the fee or flips a change's direction. |
erc20 | Marks an EVM token-contract (ERC-20) transfer (informational). |
contract | Marks a Tron token-contract (TRC-20) transfer (informational). Same concept as erc20, chain-specific name. |
The tags above are the current set. Treat it as forward-compatible — ignore any tag you don't recognize rather than failing on it, in case new tags are introduced later.
Aggregation per stream
The same raw on-chain changes are presented at different granularity depending on the stream:
| Stream / event | Aggregation | Entries per address+asset |
|---|---|---|
blockchain transaction/mempool | None — one entry per on-chain change, the detailed view. Multiple fee entries may appear separately. | many |
incoming mempool/confirmation/rollback | Combined per address+asset — the transfer as a single amount, with fee / rebate / mint each as their own entry. | ≤ 4 |
outgoing mined | Aggregated per address+asset (as above), then collapsed to the transaction's source level: address → per-address; account → merged per asset across the account's addresses; vault → merged per asset across the vault. | ≤ 4 per asset |
Account-/vault-level collapsing matters mainly on UTXO networks, where one withdrawal can spend inputs from several addresses — without it you'd get one entry per input address instead of a single meaningful debit.
Conservation: don't double-count
A transaction's on-chain effects are split across its notifications so that each custody-side amount is counted exactly once:
- The
outgoing/minednotification carries the debits (what left your addresses) — the transfer and the fee. - Any credit that lands on a custody address — the destination of an internal transfer, or a UTXO change output sent to a different vault address — is excluded from
outgoing/minedand delivered on its ownincomingnotification instead.
So the sum of a transaction's outgoing/mined entries and every related incoming entry equals the total custody-side movement — with nothing lost and nothing double-counted. Do not add a transaction's incoming amounts to its outgoing/mined amounts expecting them to overlap; they are complementary. Reconcile withdrawals from outgoing/mined, and deposits / internal-transfer destinations from incoming.
Memo-routed self-transfers
When a transaction sends from an address to itself with a memo (e.g. routing funds between memo sub-accounts), you receive two complementary notifications: the credit leg as an incoming, and the debit leg on outgoing/mined — so the movement is visible on both sides rather than cancelling out. If the memo is registered in your address book, memoInfo on the incoming credit attributes it to the sub-account.
UTXO networks: change outputs and fees
On UTXO chains (Bitcoin, etc.):
- Fee — UTXO chains have no explicit fee output, but you still receive a clean
[transfer, fee]pair onoutgoing/mined(a separatefee-tagged entry). The total fee is also available in the transactionmetadata. - Change output — a UTXO withdrawal returns the unspent remainder as a change output. How it appears depends on where it lands:
| Change destination | How it appears |
|---|---|
| Back to a source-side address (the default) | Combined into that address's debit — you won't see a separate change entry. |
| To a different address in the same vault | Its own incoming credit, carrying the change tag so you can tell a self-change apart from a real external deposit. |
Eventual consistency of the read API
The blockchain-tx list reflects the same balance changes as these notifications, but reports them per address. A just-mined transaction appears in the list right away, while its balanceChanges field may be briefly omitted until the change has been processed. Because the list is per-address (not collapsed to the transaction's source level), it reconciles with the outgoing/mined notification by sum rather than entry-for-entry.
Replay & List Notifications
Requires manageNotifications on the vault (or global Vault:read for read-only list) plus manageVaults on the organization — see Authorization.
Replay a notification
Re-deliver a previously delivered notification (debugging / recovery), optionally to a different URL:
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://acme.com/webhook/vault-updates",
"deliveryId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}' \
https://api.carabaas.com/api/v1/subscriptions/notifications/{notificationId}/replay
urlis required;deliveryIdis optional (a fresh one is generated if omitted).- Only delivered notifications can be replayed — replaying one that is still pending returns
400. - A previously successful delivery is immutable — replaying a
deliveryIdwhose recorded result was already2xxreturns409.
List notifications for a transaction
GET /api/v1/subscriptions/notifications returns a transaction's notifications, merging pending and delivered entries (deduplicated, delivered wins), each with a status of pending or delivered. Notifications keyed on neither a transaction nor a blockchain hash (e.g. pure signing progress) are not returned here.
curl -H "Authorization: Bearer $TOKEN" \
"https://api.carabaas.com/api/v1/subscriptions/notifications?transactionId={transactionId}"
See Also
- API Reference — Subscriptions
- Transactions — lifecycle, warnings, blockchain-tx list
- Processing Deposits
- Processing Withdrawals
- Reconciliation