> Guveno API reference v1.10.0, last updated October 17, 2026. The whole reference is at https://www.guveno.com/llms-full.txt. ## Webhooks Subscribe to deposit and withdrawal events and receive them at your own endpoint. > Three rules keep webhook money safe: credit a balance only on `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 `POST`s to whenever something happens on one of your wallets. End to end: - **1. Register a receiver.** `POST /webhooks` with your URL and the events you want (see *Subscribing*). Keep the `signingSecret` from the response; it is shown only once and you need it to verify deliveries. - **2. Receive the delivery.** Each event arrives as a JSON `POST` with two headers that matter: `content-type: application/json` and `x-guveno-signature: sha256=`. - **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 `id` is stable across retries; treat it as an idempotency key and ignore an `id` you have already processed. - **5. Acknowledge fast.** Return a `2xx` as soon as you have persisted the event; do the slow work (crediting balances, notifying users) afterwards. Any non-`2xx` response (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 marked`failed` 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.detected` - `deposit.confirmed` - `deposit.rejected` - `withdrawal.broadcast` - `withdrawal.confirmed` - `withdrawal.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 tag ``` **TON 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_error ``` Use `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 inside`data.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 with `confirmations: 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; `amount` is 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. No `confirmations` or `fee` yet; the send has happened and is irreversible. - `confirmed` (`withdrawal.confirmed`): the transaction is mined, executed successfully, and past the confirmation threshold. Carries `confirmations` and the network `fee` when known. - `rejected` (`withdrawal.rejected`): the send did not land, with a `reason` of `reorg`, `mempool_dropped`, `failed_on_chain`, `expired`, or `provider_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` (see*Verifying 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 **POST** `/webhooks` **GET** `/webhooks` **DELETE** `/webhooks/:id` ``` 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 **PATCH** `/webhooks/:id` 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=` 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 **POST** `/webhooks/:id/rotate-secret` Replace a webhook's signing secret, for scheduled rotation or after a suspected leak. Send a`signingSecret` 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 **GET** `/webhooks/:id/deliveries` Inspect recent attempts, response codes and errors. Failed deliveries are retried automatically. Pass`limit` (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 **GET** `/webhooks/:id/deliveries/:deliveryId` The whole record of a single attempt: `payload` is the exact JSON body we posted — the bytes the`x-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" } } ``` Source: https://www.guveno.com/docs/webhooks/