Webhooks
Subscribe to deposit and withdrawal events and receive them at your own endpoint.
deposit.confirmed (never deposit.detected), verify the x-guveno-signature header before trusting a payload, and dedupe on the envelope id, because deliveries can be retried.Integrating
A webhook integration is a single HTTPS endpoint on your side that Guveno POSTs to whenever something happens on one of your wallets. End to end:
- 1. Register a receiver.
POST /webhookswith your URL and the events you want (see Subscribing). Keep thesigningSecretfrom the response; it is shown only once and you need it to verify deliveries. - 2. Receive the delivery. Each event arrives as a JSON
POSTwith two headers that matter:content-type: application/jsonandx-guveno-signature: sha256=<hex>. - 3. Verify it is really us. Recompute the HMAC over the raw request body and compare it to the header (see Verifying signatures). Reject anything that does not match.
- 4. Deduplicate. A delivery can be retried, so the same event may arrive more than once. The envelope
idis stable across retries; treat it as an idempotency key and ignore anidyou have already processed. - 5. Acknowledge fast. Return a
2xxas soon as you have persisted the event; do the slow work (crediting balances, notifying users) afterwards. Any non-2xxresponse (or a timeout) is treated as a failed delivery and retried.
Retries use exponential backoff (≈5s, doubling, capped at 60s) for up to 5 attempts; a 429 from your endpoint is honored via its Retry-After header. After the final attempt the delivery is markedfailed and you can inspect it in the Delivery logs. Below is a complete delivery exactly as it lands on your endpoint:
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-Guveno-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
{
"id": "8f1c2e4a-7b3d-4c9a-9b1e-2a6f0d4e8c11",
"type": "deposit.confirmed",
"chain": "ethereum",
"network": "mainnet",
"companyId": 42,
"createdAt": "2026-06-14T11:55:00.000Z",
"data": {
"chain": "ethereum",
"network": "mainnet",
"txHash": "0xabc...",
"addressId": 50,
"confirmations": 12,
"status": "confirmed",
"direction": "incoming",
"amount": "1.5",
"asset": { "key": "ethereum:mainnet:usdc", "symbol": "USDC", "decimals": 6, "contractAddress": "0xa0b8..." },
"fromAddress": "0x1111...",
"toAddress": "0x2222...",
"eventType": "erc20_transfer",
"eventIndex": 0
}
}Events
deposit.detecteddeposit.confirmeddeposit.rejectedwithdrawal.broadcastwithdrawal.confirmedwithdrawal.rejected
Event payloads
Every delivery is a POST with the same envelope. chain and network are on the envelope so you can route by chain without parsing data; data varies by event type (and repeats chain/network):
{
"id": "8f1c2e4a-...", // unique event id (also the idempotency key)
"type": "deposit.confirmed", // one of the event types above
"chain": "ethereum", // "ethereum" | "bsc" | "polygon" | "arbitrum" | "bitcoin" | "dogecoin" | "xrp" | "polkadot" | "tron" | "solana" | "ton"
"network": "mainnet", // e.g. mainnet | sepolia | testnet | paseo | nile | amoy | devnet
"companyId": 42,
"createdAt": "2026-06-14T11:55:00.000Z",
"data": { /* see below */ }
}Deposit events: deposit.detected, deposit.confirmed, deposit.rejected. Each carries the asset and amount that moved, so you can credit a balance straight from the delivery. A transaction that moves more than one asset (or pays multiple of your addresses) fans out into one event per transfer: dedupe on the envelope id, and use txHash + eventIndex to tell transfers of the same tx apart:
{
"chain": "ethereum",
"network": "mainnet",
"txHash": "0xabc...",
"addressId": 50,
"confirmations": 12,
"status": "confirmed", // "pending" | "confirmed" | "rejected"
"direction": "incoming",
"amount": "1.5", // exact string in display units — never a JSON number
"asset": {
"key": "ethereum:mainnet:usdc",
"symbol": "USDC",
"decimals": 6,
"contractAddress": "0xa0b8..." // null for the chain's native asset
},
"fromAddress": "0x1111...", // present when the chain exposes it
"toAddress": "0x2222...", // your deposit address
"eventType": "erc20_transfer",
"eventIndex": 0, // stable ordinal within the tx
"logIndex": 3 // EVM log index (or "vout" on UTXO chains), when applicable
}Bitcoin only: deposit events additionally carry the full transaction topology when the provider supplies it: inputs (every source address) and outputs (every destination), each { address, amount }. Account-based chains omit these.
XRP only: deposit events carry destinationTag, the tag the payment was sent with, or null when it had none. Use it to attribute a deposit to a customer when several share one address. For a tagged payment, toAddress also reads address:tag.
"chain": "xrp",
"toAddress": "rYours...:1433698153",
"destinationTag": 1433698153 // null when the payment carried no tagTON only: deposit events always carry memo, the text comment the payment was sent with, or null when it had none. A payload that is not a readable text comment (binary data, an encrypted comment) is recorded with memo set to null, so a deposit is never attributed by a memo we could not read. Use it the way you would an XRP destination tag. Jetton deposits carry the memo from inside the token transfer, and are credited only from your address's own jetton wallet, the one the jetton master names.
"chain": "ton",
"toAddress": "UQYours...",
"memo": "4471203" // null when the payment carried no readable text comment "inputs": [
{ "address": "bc1qsource1...", "amount": "0.40000000" },
{ "address": "bc1qsource2...", "amount": "0.10141692" }
],
"outputs": [
{ "address": "bc1qyours...", "amount": "0.01411692" },
{ "address": "bc1qchange...", "amount": "0.48730000" }
]Bitcoin only: deposit.detected can fire while the transaction is still in the mempool (status: "pending", confirmations: 0), before it is mined into a block, so you learn of an incoming payment as soon as it is broadcast. It is followed by deposit.confirmed once it reaches the confirmation threshold. If the mempool transaction is replaced or double-spent and never confirms, it is instead deposit.rejected with reason: "mempool_dropped". Wait for deposit.confirmed before treating funds as final.
Withdrawal events: withdrawal.broadcast fires the moment a signed tx hits the network (status: "broadcast", no confirmations/fee yet); withdrawal.confirmed carries confirmations (and the network fee when known); withdrawal.rejected carries a reason. All three carry the asset, amount, source and destination:
{
"chain": "ethereum",
"network": "mainnet",
"withdrawalId": 7,
"txHash": "0xdef...",
"status": "broadcast", // "broadcast" | "confirmed" | "rejected"
"confirmations": 6, // confirmed only
"amount": "10.0", // exact string in display units
"asset": {
"key": "ethereum:mainnet:usdc",
"symbol": "USDC",
"decimals": 6,
"contractAddress": "0xa0b8..." // null for the chain's native asset
},
"fromAddress": "0x9999...", // the primary sending address
"sourceAddresses": ["0x9999..."], // every address spent from, primary first (several on Bitcoin/Dogecoin)
"toAddress": "0x3333...", // present when the withdrawal targets a single address
"outputs": [ // multi-recipient Bitcoin sends: the full recipient list
{ "toAddress": "bc1qa...", "amount": "0.25" },
{ "toAddress": "bc1qb...", "amount": "0.10" }
],
"fee": { // confirmed only, when the on-chain fee is known
"amount": "0.00042",
"asset": { "key": "ethereum:mainnet:eth", "symbol": "ETH", "decimals": 18, "contractAddress": null }
}
}
// withdrawal.broadcast omits "confirmations" and "fee".
// withdrawal.rejected omits "confirmations"/"fee" and adds:
// "reason": "failed_on_chain"
// one of: reorg | mempool_dropped | failed_on_chain | expired | provider_errorUse id to deduplicate: a delivery may be retried, and the same id means the same event. The payload carries the asset and amount directly; you can still look up full records via the Transactions and Withdrawals endpoints using the ids/hashes in data.
Status values
Two different things have a "status", and it helps to keep them apart: the money status insidedata.status (what happened on-chain), and the delivery status of the webhook attempt itself (whether we reached your endpoint; see Delivery logs).
Deposit data.status, carried by the matching deposit.* event:
pending(deposit.detected): we have seen the transaction but it is not yet final. On Bitcoin this can fire from the mempool withconfirmations: 0, before it is mined. Do not credit funds yet.confirmed(deposit.confirmed): the deposit reached the chain's confirmation threshold. This is the signal to credit a balance;amountis the exact value delivered to your address.rejected(deposit.rejected): a previously detected deposit will not settle (e.g.reason: "mempool_dropped"after a replacement/double-spend). Reverse anything you provisionally credited.
Withdrawal data.status, carried by the matching withdrawal.* event:
broadcast(withdrawal.broadcast): the signed transaction has been published to the network. Noconfirmationsorfeeyet; the send has happened and is irreversible.confirmed(withdrawal.confirmed): the transaction is mined, executed successfully, and past the confirmation threshold. Carriesconfirmationsand the networkfeewhen known.rejected(withdrawal.rejected): the send did not land, with areasonofreorg,mempool_dropped,failed_on_chain,expired, orprovider_error.
confirmed means the funds moved, not just that the transaction is in a block. A mined transaction can still have reverted, and on EVM chains a non-standard token contract can return failure without reverting at all — a successful receipt that transferred nothing. Guveno settles a token withdrawal only against a matching Transfer log (right contract, sender, recipient and exact amount), so a withdrawal with no such evidence stays broadcast rather than being reported as paid. Expect a confirmation to lag a block explorer slightly; never treat inclusion alone as settlement.
Confirming a delivery
There are two independent ways to be sure of what a delivery is telling you. Use the first always, and the second whenever a webhook drives money movement on your side.
1. Verify the signature (authenticity). Every delivery is signed, so you can trust the payload without a round-trip: recompute the HMAC over the raw body and compare it to x-guveno-signature (seeVerifying signatures below). A matching signature proves the event came from Guveno and was not tampered with; for most integrations a verified deposit.confirmed is enough to credit a balance.
2. Reconcile against the API (authority). When you want a second, authoritative source (or you missed a delivery), read the record back from the API using the ids/hashes in data. The API is the source of truth; the webhook is a notification. This also lets you confirm status even if your endpoint was down when the event fired.
# Deposit: find the transaction by its on-chain hash (search matches the hash)
curl "https://api.guveno.com/v1/transactions?search=0xabc..." \
-H "Authorization: Bearer gv_live_your_key_here"
# Withdrawal: fetch the current state by id
curl https://api.guveno.com/v1/withdrawals/7 \
-H "Authorization: Bearer gv_live_your_key_here"To confirm whether we reached you (as opposed to what happened on-chain), check the delivery log for the webhook: its status is pending, delivered, or failed, with the receiver's responseCode and the last lastError. See Delivery logs.
Subscribing
curl https://api.guveno.com/v1/webhooks \
-H "Authorization: Bearer gv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "type": "api", "config": { "url": "https://example.com/guveno" }, "events": ["*"] }'The response includes a signingSecret, shown only once, used to verify deliveries. To pin a secret you already manage, pass your own signingSecret (16–256 chars) in the create body; omit it and one is generated for you.
Scope to specific wallets: pass walletIds (an array of wallet ids) to receive events only for those wallets. Omit it or send an empty array to receive events for all wallets in your company — offered only to a caller who can already see every wallet, since an all-wallets webhook also picks up the ones added later. Every webhook in the list response carries its walletIds so you can tell its scope.
A webhook is only as visible as the wallets it watches. Listing, editing, rotating its secret, reading its deliveries and deleting it all require being able to see every wallet it is scoped to — its delivery config and its event payloads describe those wallets, so they follow the same team boundary the events themselves do.
curl https://api.guveno.com/v1/webhooks \
-H "Authorization: Bearer gv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "type": "api", "config": { "url": "https://example.com/guveno" }, "events": ["deposit.confirmed"], "walletIds": [12, 34] }'Changing a webhook
Change where a webhook sends, which events and wallets it covers, or switch it off, without deleting it and losing its delivery log. Send only the fields you are changing: config, events,walletIds and enabled. An empty body is refused. config replaces the stored config whole, so a Telegram webhook takes both botToken and chatId again.
The type is fixed when the webhook is created; to move from Slack to an HTTP endpoint, add a new webhook and delete the old one. The signing secret is neither changed nor returned here (use Rotating the secret). The same checks as creating apply: every wallet in walletIds must be one you can see, and an empty array (all wallets, including ones added later) is only for a caller who sees every wallet.
Deliveries still waiting to be sent go to the new destination. While a webhook is disabled nothing is sent to it and new events are not queued for it.
curl -X PATCH https://api.guveno.com/v1/webhooks/42 \
-H "Authorization: Bearer gv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "config": { "url": "https://example.com/guveno-v2" }, "events": ["deposit.confirmed"] }'Verifying signatures
Every delivery includes an x-guveno-signature: sha256=<hex> header, an HMAC-SHA256 of the raw request body keyed with your signing secret. Verify it before trusting the payload:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, signingSecret) {
const expected = "sha256=" + createHmac("sha256", signingSecret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Rotating the secret
Replace a webhook's signing secret, for scheduled rotation or after a suspected leak. Send asigningSecret to set your own, or an empty body to have one generated. The new secret is returned once in the response; deliveries are signed with it immediately, so update your receiver in the same change.
curl -X POST https://api.guveno.com/v1/webhooks/42/rotate-secret \
-H "Authorization: Bearer gv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{}'Delivery logs
Inspect recent attempts, response codes and errors. Failed deliveries are retried automatically. Passlimit (1–100, default 50) to say how many to return; the newest come first. Each row carries the delivery's status, how many attempts it has taken, the responseCode your endpoint answered with, the lastError if it failed, and nextAttemptAt when another try is due.
curl https://api.guveno.com/v1/webhooks/42/deliveries?limit=20 \
-H "Authorization: Bearer gv_live_your_key_here"Inspecting one delivery
The whole record of a single attempt: payload is the exact JSON body we posted — the bytes thex-guveno-signature header is computed over, so it is what a signature mismatch is debugged against — and responseBody is what your endpoint replied, truncated to 2KB. Both are left out of the list above, which stays small enough to poll; read them one delivery at a time.
responseBody is a diagnostic, never a signal: the delivery's outcome is decided by the status code alone. It is null when your endpoint returned no body, and responseCode is null when nothing reached it at all. Any secret belonging to the webhook itself is redacted out before it is stored.
curl https://api.guveno.com/v1/webhooks/42/deliveries/9001 \
-H "Authorization: Bearer gv_live_your_key_here"
{
"delivery": {
"id": 9001,
"webhookId": 42,
"eventId": "7b0d9f2a-1c44-4e8b-9b31-2f6ad5c10e77",
"eventType": "deposit.confirmed",
"status": "delivered",
"attempts": 1,
"responseCode": 200,
"lastError": null,
"responseBody": "{\"ok\":true}",
"payload": { "id": "7b0d9f2a-1c44-4e8b-9b31-2f6ad5c10e77", "type": "deposit.confirmed", "data": { } },
"nextAttemptAt": "2026-09-20T09:14:02.118Z",
"createdAt": "2026-09-20T09:14:02.118Z",
"updatedAt": "2026-09-20T09:14:02.400Z"
}
}