Webhooks
What webhooks are for, how Sovera delivers them, how to verify and process them safely, and every event your endpoint can receive.
Webhooks are how the platform tells your backend that something happened, without you asking. A deposit lands, a withdrawal is paid out, a transfer fails, a balance changes: Sovera sends an event to the HTTPS endpoint you registered, right when it happens.
Use them to:
- React in real time instead of polling. Show a pending deposit on
user.deposit.received, and make it spendable onledger.wallet.credited. Mark a withdrawal as sent ontreasury.withdrawal.executedorcustody.withdrawal.executed. - Receive verification codes. When a user starts a withdrawal or a transfer, the code that confirms it is delivered only by webhook (
user.withdrawal.verification_code_issued,user.transfer.verification_code_issued). The API never returns it. - Keep your ledger in step with Sovera's, using the
ledger.*events and the resulting balances they carry.
Register and test your endpoint in Customer Webhooks. Every customer has one endpoint, and it receives every event.
How events are delivered
Every event arrives as an HTTPS POST with Content-Type: application/json, the headers you configured on your endpoint, and this body:
{
"event_id": "0f7d2c4e-8a1b-4c3d-9e5f-6a7b8c9d0e1f",
"event_type": "user.withdrawal.verification_code_issued",
"occurred_at": "2026-10-14T14:03:12.081Z",
"resource_type": "withdrawal",
"resource_id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d",
"sequence_number": 1,
"data": { }
}| Field | Meaning |
|---|---|
event_id | Unique per event. Use it to drop duplicates: the same event can arrive more than once. |
event_type | What happened, in dotted lowercase (user.deposit.received). See Event catalogue. |
occurred_at | When it happened, UTC, with milliseconds. |
resource_type, resource_id | The resource the event is about (deposit, withdrawal, transfer, wallet, batch, gas) and its id. Each event in the catalogue says which one it uses. |
sequence_number | Starts at 1 and grows by one for each event about the same resource (same resource_type and resource_id). Use it to apply the events of one resource in order. |
data | The event payload. Its fields depend on event_type. |
The body has only these seven keys. The name of the event is in event_type; the body has no top-level event key.
Retries and timeouts
- Answer with any
2xxstatus as soon as you've stored the event. Do the real work afterwards. - A network error, a timeout or a
5xxanswer is retried. Sovera makes up to 3 attempts in total, waiting 1 second and then 2 seconds between them. - A
4xxanswer isn't retried. Sovera takes it as a permanent refusal. - Each attempt times out after 10 seconds.
- Redirects aren't followed. A
3xxanswer counts as a failed attempt. - After the last failed attempt the event isn't sent again. It stays recorded with
delivery_status: SUSPENDED. List it withGET /v1/events. - An event produced while no endpoint is configured is recorded too, and never sent.
Security
- Your endpoint must be HTTPS on a public host. Sovera checks the address it resolves to before every attempt. It never delivers to private, loopback or internal addresses, and it doesn't retry an endpoint that fails this check.
- Add the auth headers your backend expects (an API key, a bearer token) when you register the endpoint. They're sent with every event and never shown back in full.
- When Sovera has set a signing secret for your endpoint, every delivery carries
X-Signature-256: sha256=<hex>. It's the HMAC-SHA256 of the raw request body with that secret. Compute it over the exact bytes you received, before parsing the JSON. Compare it in constant time, and reject the event if it doesn't match.
Other bodies your endpoint can receive
One body reaches the same URL without the envelope above: the test notification. Verify Webhook sends a body with "test": true and no event_type. Ignore it.
Every other body is an event in the envelope above. That includes withdrawal codes that reach Sovera by email: they arrive only as user.withdrawal.verification_code_issued.
Processing events safely
- Verify, store, answer. Check the signature (or your auth header), store the raw event keyed by
event_id, and answer2xx. Process it from your own queue. - Be idempotent. If an
event_idis already stored, answer2xxand do nothing else. - Order per resource. Events about different resources can arrive in any order. For one resource, apply them in
sequence_numberorder, and skip an event whose number is lower than the last one you applied. - Tolerate the unknown. New event types, new fields and new enum values can be added at any time. Ignore an
event_typeyou don't handle, ignore fields you don't recognise, and treat an unknownreason_codeas a generic failure. - Reconcile. If your endpoint was down, list what you missed with
GET /v1/events(filter bycreated_at,type,resource_type,resource_idordelivery_status). Then fetch each resource's current state with itsGETendpoint. - Handle verification codes with care. Show a code only to the user it belongs to, never log it, and discard it once it's used or expired (
expires_at). Sovera never stores the code and never returns it fromGET /v1/events.
Reading data
- Amounts and balances are decimal strings, such as
"25.00000000". Don't parse them as floats, and don't assume a fixed number of decimals. - Currency codes in
dataare lowercase asset codes (btc,eth,usdc). The API answers with uppercase codes, so compare them case-insensitively. - Timestamps are ISO-8601 in UTC.
- An optional field that doesn't apply is left out. It isn't sent as
null. The exception is a field the catalogue describes as nullable, such assweep_tx_hash,addresses[].networkor theamountof a treasury hold: it's always present, andnullmeans the value isn't known. resource_typeis lowercase in the webhook body.GET /v1/eventsreturns the same value in UPPER_SNAKE (DEPOSIT).
Event catalogue
These are all the events Sovera sends today.
| Group | Events |
|---|---|
| Verification codes | user.withdrawal.verification_code_issued, user.transfer.verification_code_issued |
| Deposits | user.deposit.received, user.deposit.screening_passed, user.deposit.screening_failed, user.deposit.swept_to_treasury, user.deposit.returned, user.deposit.failed, user.deposit.suspended_address_received, custody.deposit.confirmed |
| Funds holds | user.funds.frozen, user.funds.released, treasury.funds.frozen, treasury.funds.released |
| Ledger | ledger.wallet.added, ledger.wallet.credited, ledger.wallet.debited, ledger.wallet.reversed, ledger.transfer.failed |
| Withdrawals | treasury.withdrawal.requested, treasury.withdrawal.approved, treasury.withdrawal.executed, treasury.withdrawal.failed, custody.withdrawal.requested, custody.withdrawal.executed |
| Treasury | treasury.batch.executed, treasury.batch.failed, treasury.deposit.received, treasury.deposit.screened, treasury.wallet.flagged, treasury.wallet.created, treasury.contamination.remediated, treasury.wallet.rotated |
| Wallets | user.wallet.created |
| Gas | gas.primary.threshold_breached, gas.backup.activated, gas.backup.threshold_breached |
The examples below show only data. The envelope around it is always the one in How events are delivered.
Verification codes
These two events are the only channel that carries a verification code.
user.withdrawal.verification_code_issued
Sent when a user's crypto withdrawal needs a code: on initiate, and on resend. Resource: withdrawal, and resource_id is the withdrawal id.
A withdrawal follows one of two routes, and the event differs slightly:
| Treasury pays directly | Treasury replenished first | |
|---|---|---|
| How the code reaches Sovera | Sovera issues it. | By email, read by your email poller. |
| Code lifetime | 5 minutes. | 15 minutes from when the email arrives. |
trigger | issued on initiate, resent on resend. | Always issued, also after a resend. |
attempts_remaining | Sent (3 with every new code). | Not sent. Custody counts the attempts. |
network | Sent. | Can be absent. |
{
"code": "482913",
"user_id": "5299e81c-3a8c-5b64-a112-b93bf43de74b",
"withdrawal_id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d",
"expires_at": "2026-10-14T14:08:12.081Z",
"trigger": "issued",
"attempts_remaining": 3,
"amount": "25.00000000",
"currency": "usdc",
"network": "ETHEREUM",
"destination_address": "0x8ba1f109551bD432803012645Ac136ddd64DBA72"
}{
"code": "731406",
"user_id": "5299e81c-3a8c-5b64-a112-b93bf43de74b",
"withdrawal_id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d",
"expires_at": "2026-10-14T14:19:40.512Z",
"trigger": "issued",
"amount": "25.00000000",
"currency": "usdc",
"network": "ETHEREUM",
"destination_address": "0x8ba1f109551bD432803012645Ac136ddd64DBA72"
}| Field | Meaning |
|---|---|
code | The 6-digit code. Pass it to the confirm endpoint. |
expires_at | When the code stops working. |
trigger | issued or resent. |
attempts_remaining | Confirmation attempts left with this code. |
network | Canonical network code of the withdrawal. |
destination_address | Where the funds go. Show it next to the code, so the user knows what they approve. |
user.transfer.verification_code_issued
Sent when a wallet-to-wallet transfer is created, and when you resend its code. Resource: transfer, and resource_id is the transfer id. The code lasts 5 minutes and allows 3 attempts.
{
"user_id": "5299e81c-3a8c-5b64-a112-b93bf43de74b",
"transfer_id": "c2a4e6f8-1b3d-4f5a-9c7e-0d2f4a6b8c1e",
"code": "907215",
"expires_at": "2026-10-14T14:45:02.517Z",
"trigger": "issued",
"attempts_remaining": 3
}trigger is issued on create and resent on resend. attempts_remaining is 3 with every new code.
Deposits
A deposit to a user's address moves through these events:
user.deposit.receivedandledger.wallet.added: the deposit is seen and stays pending until screening ends.user.deposit.screening_passedandledger.wallet.credited: the funds become available. Oruser.deposit.screening_failed: see that event for what follows.user.deposit.swept_to_treasuryandcustody.deposit.confirmed: the funds move on to the treasury and to custody.
All user.deposit.* events use resource deposit, and resource_id is the deposit id.
user.deposit.received
Sent when a deposit to one of a user's addresses is first seen on chain, before screening. The funds stay pending, and can't be spent, until screening ends.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"amount": "150.00000000",
"currency": "usdc",
"source_address": "0x3f5CE5FBFe3E9af3971dD833D26bA9b5C936f0bE",
"tx_hash": "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060"
}source_address can be absent when the deposit has several senders. tx_hash can be absent while the transaction isn't published yet.
user.deposit.screening_passed
Sent when every sender of the deposit screened as low risk. ledger.wallet.credited follows, with reason screening_cleared.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5"
}user.deposit.screening_failed
Sent when screening rejects the deposit. The deposit address is deactivated.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"reason_code": "high_risk"
}reason_code | Meaning | What follows |
|---|---|---|
elevated_risk | A sender has medium risk. | Sovera returns the deposit to its sender: ledger.wallet.reversed, then user.deposit.returned. |
high_risk | A sender has high risk. | Same as elevated_risk. |
severe_risk | A sender has severe risk. | Same as elevated_risk. |
blacklisted | The sender or the destination address is blocked. | The funds stay frozen for review: user.funds.frozen. |
A deposit that can't be screened, or whose screening service stays unavailable, sends no event. It stays pending for manual review.
user.deposit.swept_to_treasury
Sent when a cleared deposit starts moving from the user's address to the treasury. It's sent again, with batch_id, when the batch that contains the deposit is forwarded from the treasury.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"sweep_tx_hash": null,
"destination": "treasury"
}{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"sweep_tx_hash": "0x9a1f3c7e5b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a",
"destination": "treasury",
"batch_id": "7e2d9c41-5b8a-4f3e-a1d6-2c9b8e7f6a50"
}| Field | Meaning |
|---|---|
sweep_tx_hash | The on-chain hash of the transaction, or null when it isn't known. Always null on the first event, because the sweep has only started. On the event with batch_id, the hash of the transaction that forwarded the batch, or null if it isn't known yet. No event sends the hash again once the transaction confirms. |
destination | Always treasury. |
batch_id | Only on the second event. Matches treasury.batch.*. |
user.deposit.returned
Sent when the automatic return of a rejected deposit settles on chain.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"destination_address": "0x3f5CE5FBFe3E9af3971dD833D26bA9b5C936f0bE",
"return_tx_hash": "0x1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f4a6c8e0b1d3f"
}destination_address is the address that sent the deposit.
user.deposit.failed
Sent when a signing policy refused the transaction that moves the deposit on. The deposit stays where it is, and Sovera doesn't retry it automatically.
{
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"reason_code": "policy_rejected"
}reason_code is always policy_rejected today. The payload also defines an optional wallet_id. It's left out when the deposit can't be matched to a wallet, which is the case for every event sent today.
user.deposit.suspended_address_received
Sent when funds arrive at a deposit address that was deactivated. The deposit isn't processed.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"deposit_id": "5e7a9c1b-3d5f-4a7c-9e1b-3d5f7a9c1b3d",
"amount": "40.00000000",
"currency": "usdc",
"source_address": "0x3f5CE5FBFe3E9af3971dD833D26bA9b5C936f0bE",
"destination_address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F",
"tx_hash": "0x2e4a6c8e0b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f4a",
"reason_code": "address_suspended"
}destination_address is the deactivated address. reason_code is always address_suspended.
custody.deposit.confirmed
Sent when the transaction that forwards a batch to custody settles on chain. One event goes out per deposit in the batch. Resource: deposit, and resource_id is the deposit id.
{
"reference_id": "7e2d9c41-5b8a-4f3e-a1d6-2c9b8e7f6a50",
"amount": "150.00000000",
"currency": "usdc"
}reference_id is the batch id. amount is the deposit's amount.
Funds holds
user.funds.frozen
Sent when a deposit is blocked (user.deposit.screening_failed with blacklisted). Its funds stay frozen for review. Resource: wallet, and resource_id is the wallet id.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"amount": "150.00000000",
"currency": "usdc",
"reason_code": "screening_failed",
"status": "FROZEN",
"hold_id": "hold_deposit_96abb315-99d8-59e7-a333-1007dba37ad5",
"available": "320.00000000",
"held": "0.00000000",
"balance": "320.00000000"
}| Field | Meaning |
|---|---|
amount | The frozen amount. |
reason_code | Why the funds are frozen. Always screening_failed today. |
status | Always FROZEN on this event. |
hold_id | Identifies this hold. |
available, held, balance | The balances of this wallet after the change. The frozen amount isn't part of them. See Resulting balances. |
Sovera then reviews the deposit. If the review approves it, user.funds.released follows. If it rejects it, the funds stay frozen and no event is sent.
user.funds.released
Sent when the review of a deposit frozen by user.funds.frozen approves it. The frozen amount becomes available in the same wallet, once. ledger.wallet.credited with reason screening_cleared follows, with the same balances. Resource: wallet, and resource_id is the wallet id.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"amount": "150.00000000",
"currency": "usdc",
"reason_code": "screening_failed",
"status": "ACTIVE",
"hold_id": "hold_deposit_96abb315-99d8-59e7-a333-1007dba37ad5",
"available": "470.00000000",
"held": "0.00000000",
"balance": "470.00000000"
}| Field | Meaning |
|---|---|
deposit_id, hold_id | The same values as on user.funds.frozen. Match the two events by hold_id. |
amount | The released amount. |
reason_code | Why the funds were frozen: screening_failed. |
status | Always ACTIVE on this event: the funds can be spent. |
available, held, balance | The balances of this wallet after the release, now including the released amount. See Resulting balances. |
A hold announced before the 2026-10-15 update can't be released this way: approving that deposit moves no funds and sends no event.
treasury.funds.frozen and treasury.funds.released
Sent during a treasury wallet rotation (see Treasury). treasury.funds.frozen is sent when the rotation starts, and treasury.funds.released when it ends. Both use the same hold_id. Resource: wallet, and resource_id is the contaminated treasury wallet.
{
"amount": "500.00000000",
"currency": "usdc",
"reason_code": "contamination",
"scope": "treasury",
"hold_id": "hold_5a7c9e1b-3d2f-4a6b-8c0d-1e2f3a4b5c6d"
}| Field | Meaning |
|---|---|
amount | The amount of the contaminated deposit the hold covers: the same amount treasury.deposit.received reported. null if that deposit carried no amount. |
currency | The asset of the deposit that triggered the rotation. |
reason_code | Always contamination today. |
scope | Always treasury. |
hold_id | The same value on the frozen and the released event. |
Ledger
Ledger events report balance movements in your users' wallets. All ledger.wallet.* events use resource wallet, and resource_id is the wallet id.
Resulting balances
Every ledger.wallet.* and user.funds.* event carries the balances that result from the change. They follow one rule: they're the balances, in the event's currency, of the wallet named in the event (wallet_id, also the resource_id). They're never the user's totals across wallets.
| Field | Meaning |
|---|---|
available | That wallet's funds that can be spent. |
held | That wallet's funds reserved for an operation that hasn't run yet, such as a withdrawal that hasn't been debited. |
balance | available plus held. |
A deposit still pending screening isn't part of any of them, and neither is a deposit frozen by user.funds.frozen. Its amount reaches available and balance when ledger.wallet.credited (or user.funds.released) makes it spendable. The balances are exact decimal strings with at least 8 decimals.
ledger.wallet.added
Sent with user.deposit.received. The deposit enters the wallet as pending. Pending funds aren't part of the balances, so the balances don't include the new deposit yet.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"amount": "150.00000000",
"currency": "usdc",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"available": "320.00000000",
"held": "0.00000000",
"balance": "320.00000000"
}ledger.wallet.credited
Sent when funds become available in a wallet.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"amount": "150.00000000",
"currency": "usdc",
"reason": "screening_cleared",
"available": "470.00000000",
"held": "0.00000000",
"balance": "470.00000000"
}reason | When | Extra field |
|---|---|---|
screening_cleared | A deposit passed screening, or a frozen deposit was approved (right after user.funds.released). Its pending funds become available. | — |
transfer | The receiving wallet of a transfer. | transfer_id |
withdrawal_reversal | A withdrawal failed after its funds were debited. The funds come back, one event per wallet that paid. | withdrawal_id |
On withdrawal_reversal, amount is that wallet's share as it was recorded, and it can have fewer than 8 decimals. The balances always have at least 8.
ledger.wallet.debited
Sent when funds leave a wallet.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"amount": "25.00000000",
"currency": "usdc",
"reason": "withdrawal",
"available": "445.00000000",
"held": "0.00000000",
"balance": "445.00000000"
}reason | When | Extra field |
|---|---|---|
withdrawal | A confirmed withdrawal spent its reserved funds. A withdrawal can be paid from several wallets: one event goes out per wallet, with that wallet's amount. | — |
transfer | The sending wallet of a transfer. | transfer_id |
The withdrawal debit doesn't carry the withdrawal id. Match it through the withdrawal's debited_wallets.
ledger.wallet.reversed
Sent when the automatic return of a rejected deposit starts. The deposit's pending funds leave the wallet. They were never part of the balances, so the balances don't drop.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"amount": "150.00000000",
"currency": "usdc",
"deposit_id": "96abb315-99d8-59e7-a333-1007dba37ad5",
"available": "320.00000000",
"held": "0.00000000",
"balance": "320.00000000"
}ledger.transfer.failed
Sent when a transfer can't go through. Resource: transfer, and resource_id is the transfer id.
{
"transfer_id": "c2a4e6f8-1b3d-4f5a-9c7e-0d2f4a6b8c1e",
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"amount": "10.00000000",
"currency": "usdc",
"reason_code": "invalid_otp"
}wallet_id is the sending wallet.
reason_code | Meaning |
|---|---|
invalid_otp | The third wrong code blocked the transfer. |
account_frozen | The account is frozen. The transfer stays PENDING. |
insufficient_available_balance | The available balance dropped before confirm. The transfer is FAILED. |
unknown | The ledger couldn't move the funds. The transfer is FAILED. |
Withdrawals
All withdrawal events use resource withdrawal, and resource_id is the withdrawal id. The events depend on the route:
- Treasury pays directly:
treasury.withdrawal.requested, thentreasury.withdrawal.approvedwhen the code is accepted,ledger.wallet.debited, andtreasury.withdrawal.executed. - Treasury replenished first:
custody.withdrawal.requested, thenledger.wallet.debitedandcustody.withdrawal.executedwhen custody accepts the code.
A failure or a cancellation sends treasury.withdrawal.failed on either route. No event announces that a withdrawal settled on chain: read the withdrawal to follow it to COMPLETED. If it fails after the debit, ledger.wallet.credited with reason withdrawal_reversal gives the funds back.
treasury.withdrawal.requested
Sent when a withdrawal paid directly by the treasury is initiated.
{
"withdrawal_id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d",
"amount": "25.00000000",
"currency": "usdc",
"address": "0x8ba1f109551bD432803012645Ac136ddd64DBA72"
}address is the destination address. A fiat withdrawal sends this event too, with an empty address.
treasury.withdrawal.approved
Sent when the code is accepted and the withdrawal moves to PROCESSING.
{
"withdrawal_id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d"
}treasury.withdrawal.executed
Sent when the treasury has sent the payment.
{
"withdrawal_id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d",
"amount": "25.00000000",
"currency": "usdc",
"address": "0x8ba1f109551bD432803012645Ac136ddd64DBA72",
"tx_hash": "0x7b9d2f4a6c8e0b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d"
}tx_hash can be absent when it isn't known yet.
treasury.withdrawal.failed
Sent when a withdrawal fails or is cancelled.
{
"withdrawal_id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d",
"reason_code": "cancelled"
}reason_code | Meaning |
|---|---|
cancelled | You cancelled the withdrawal. |
insufficient_funds | The funds didn't cover the payout. |
insufficient_gas | The gas balance didn't cover the network fee. |
invalid_address | The destination address was refused. |
invalid_otp | The code was refused at execution. |
network_error | The network call failed. |
custody_unavailable | Custody couldn't process it. |
account_frozen | The account is frozen. |
policy_rejected | A signing policy refused the transaction. |
unknown | Any other error. |
custody.withdrawal.requested
Sent when a withdrawal that needs the treasury to be replenished first is initiated.
{
"withdrawal_id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d",
"amount": "25.00000000",
"currency": "usdc"
}custody.withdrawal.executed
Sent when custody accepts the code of such a withdrawal and its funds are debited.
{
"withdrawal_id": "3b1f0c52-7d4e-4a9b-8c21-5e6f7a8b9c0d",
"amount": "25.00000000",
"currency": "usdc",
"tx_hash": "0x7b9d2f4a6c8e0b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d"
}tx_hash can be absent when custody doesn't return it.
Treasury
These events describe your treasury: deposit batches, deposits from outside your users, and wallet rotation.
treasury.batch.executed
Sent when every transfer of a deposit batch was sent from the treasury to custody. Resource: batch, and resource_id is the batch id.
{
"batch_id": "7e2d9c41-5b8a-4f3e-a1d6-2c9b8e7f6a50",
"amount": "1250.00000000",
"currency": "usdc",
"transactions": [
{
"tx_hash": "0x4c6e8b0d1f3a5c7e9b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e",
"amount": "1250.00000000",
"currency": "usdc"
}
]
}amount is the batch total. transactions lists each on-chain transfer.
treasury.batch.failed
Sent when a batch couldn't be sent, in full or in part. Resource: batch.
{
"batch_id": "7e2d9c41-5b8a-4f3e-a1d6-2c9b8e7f6a50",
"reason_code": "partial_failure",
"succeeded_count": 1,
"failed_count": 1
}reason_code | Meaning |
|---|---|
partial_failure | Some transfers were sent and some failed. |
all_transfers_failed | No transfer was sent. |
no_deposit_address | No custody deposit address was available. |
policy_rejected | A signing policy refused the batch transaction. |
succeeded_count and failed_count count transfers. With no_deposit_address, failed_count is the number of deposits.
treasury.deposit.received
Sent when funds reach the treasury from an address that isn't one of your users' addresses. Resource: deposit, and resource_id is the deposit id.
{
"deposit_id": "5a7c9e1b-3d2f-4a6b-8c0d-1e2f3a4b5c6d",
"amount": "500.00000000",
"currency": "usdc",
"source_address": "0x281055Afc982d96fAB65b3a49cAc8b878184Cb16",
"source": "external",
"tx_hash": "0x8e0b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f4a6c8e0b"
}source is always external.
treasury.deposit.screened
Sent when screening of that deposit returns a result. Resource: deposit.
{
"deposit_id": "5a7c9e1b-3d2f-4a6b-8c0d-1e2f3a4b5c6d",
"risk_result": "low"
}risk_result is low, medium, high or severe. If screening returns no result, no event is sent. A medium, high or severe result starts a treasury wallet rotation.
Treasury wallet rotation
When an outside deposit to the treasury is risky, Sovera replaces the treasury wallet. The events arrive in this order:
treasury.wallet.flagged: the old wallet is flagged as contaminated.treasury.wallet.created: the new wallet is ready.treasury.funds.frozen: see Funds holds.treasury.contamination.remediated: the clean funds were moved to the new wallet.treasury.funds.released.treasury.wallet.rotated: the new wallet replaces the old one for future sweeps.
All of them use resource wallet. resource_id is the old wallet, except on treasury.wallet.created, where it's the new one.
treasury.wallet.flagged
{
"wallet_id": "4f8e2a1c9b7d",
"reason_code": "contamination"
}reason_code is always contamination today.
treasury.wallet.created
{
"wallet_id": "9c3b7e1f2a6d",
"addresses": [
{
"address_id": "e1a7c3b9d5f2",
"currency": "eth",
"network": "ETHEREUM",
"address": "0x9d1F2a3B4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F"
}
]
}addresses lists one entry per network of the new wallet. currency is the network's native asset. network is the canonical network code, or null if the network isn't one Sovera supports.
treasury.contamination.remediated
{
"wallet_id": "4f8e2a1c9b7d",
"amount": "500.00000000",
"currency": "usdc",
"sweep_tx_hash": "0x3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f4a6c8e0b1d3f5a7c9e2b4d6f8a0c1e3b5d"
}| Field | Meaning |
|---|---|
currency | The asset that triggered the rotation. It stays in the old wallet. |
amount | The balance of that asset left isolated in the old wallet, read when the clean funds are moved out. If it can't be read, the amount of the contaminated deposit. null if neither is known. |
sweep_tx_hash | The on-chain hash of the first transaction that moved clean funds. null when nothing was moved or the hash isn't known yet. |
treasury.wallet.rotated
{
"wallet_id": "4f8e2a1c9b7d",
"new_wallet_id": "9c3b7e1f2a6d",
"new_addresses": [
{
"address_id": "e1a7c3b9d5f2",
"currency": "eth",
"network": "ETHEREUM",
"address": "0x9d1F2a3B4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F"
}
]
}new_addresses has the same entries as addresses on treasury.wallet.created.
Wallets
user.wallet.created
Sent when a wallet is created for one of your users. Resource: wallet, and resource_id is the wallet id.
{
"wallet_id": "fd956820-0446-5e40-a0fe-8fff794b7fd6",
"wallet_type": "virtual",
"status": "ACTIVE",
"addresses": [
{
"address_id": "2b7c1e9a-4d3f-4a8b-9c6e-5f1d2a3b4c5d",
"currency": "usdc",
"network": "ETHEREUM",
"address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F"
}
]
}| Field | Meaning |
|---|---|
wallet_id | The wallet id the API returns. |
wallet_type | virtual or real. |
status | Always ACTIVE on this event. |
addresses | The wallet's deposit addresses, one per currency and network. It can be shorter than the wallet's networks if some addresses weren't ready yet. |
addresses[].network | The canonical network code, the same value List Deposit Addresses returns for that address. null if the network isn't one Sovera supports. |
No separate event is sent for each address.
Gas
Gas events warn that the wallets paying network fees are running low. Sovera checks them before each transaction that needs gas, and sends an event every time a check finds a low balance. Resource: gas, and resource_id is primary or backup.
gas.primary.threshold_breached
Sent when the primary gas wallet is at or below its threshold.
{
"source": "primary",
"balance": "0.00800000",
"threshold": "0.01000000",
"currency": "eth",
"remaining_transactions": 4
}| Field | Meaning |
|---|---|
balance | The wallet's balance in its native asset (currency). |
threshold | The configured low-balance threshold. |
remaining_transactions | How many more transactions the balance covers at the current fee estimate. Absent when there's no estimate. |
gas.backup.activated
Sent when the primary gas wallet is empty, so the backup wallet has to cover the fees.
{
"reason": "primary_depleted"
}gas.backup.threshold_breached
Sent when the backup gas wallet is at or below its threshold. The fields are the same as on gas.primary.threshold_breached, with source: "backup".
{
"source": "backup",
"balance": "0.00200000",
"threshold": "0.00500000",
"currency": "eth",
"remaining_transactions": 1
}API conventions
The rules every Crypto as a Service endpoint follows: envelope, errors, idempotency, rate limits, pagination, filters, money, statuses, timestamps, networks, and pairs.
Error Codes Reference
Every error code the Crypto as a Service API returns, the HTTP status behind it, and how to debug a failed call.