# Payout Webhook BlockBee notifies your endpoint when a payout reaches a terminal state: confirmed (`status=done`) or failed (`status=error`). Use it to reconcile withdrawals: mark a payout complete in your system, or surface the failure reason when one occurs. You configure the destination URL and method per profile in your BlockBee dashboard payout settings. If no URL is set, BlockBee sends no payout webhook. **Method:** You choose in your payout settings: - `POST` (default) - Payload sent in the request body as `application/x-www-form-urlencoded`. - `GET` - Payload sent as URL query parameters. **Content-Type:** The payout webhook is always delivered as form data: `application/x-www-form-urlencoded` for `POST`, query-string parameters for `GET`. There is no JSON option. --- ## How It Works When a payout settles or fails, BlockBee sends one signed HTTP request to your configured URL and waits for an acknowledgement. Respond with HTTP 200 and you're done. Respond with anything else, or time out, and BlockBee retries with exponential backoff. ```mermaid sequenceDiagram participant BlockBee participant Endpoint as "Your Endpoint" Note over BlockBee: Payout reaches a terminal state (done / error) BlockBee->>Endpoint: Signed webhook (x-ca-signature) Endpoint-->>BlockBee: HTTP 200 (acknowledge) Note over BlockBee,Endpoint: Non-200 / timeout / connection error → retry with backoff ``` BlockBee sends exactly one notification per state transition: - **`done`** - The payout was confirmed on-chain. - **`error`** - The payout failed. The `error` field carries the reason. Expect both states. A payout that fails still produces a webhook, with `status=error` and `error` populated. Requests arrive with the `User-Agent` `BlockBee/1.0 (+https://docs.blockbee.io/webhooks)`. > **INFO** >**The payout webhook does not include the on-chain `txid`.** Find the transaction hash on the payout itself in your dashboard. --- ## Configuring the Webhook Payout webhook settings are configured per profile from your dashboard. You never call an API to set them up. 1. In your [BlockBee dashboard](https://dash.blockbee.io), select the profile you want to configure and open **Profile Settings → Payout settings**. 2. Enter your **Endpoint URL**, a public **HTTPS** URL. Internal and loopback addresses are rejected, including on redirects. 3. Pick the **Method**: `POST` (default) or `GET`. 4. Click **Save**. The page prefills with the URL and method you last saved. To turn the webhook off, clear the **Endpoint URL** and save again. The dashboard asks you to confirm first, since this stops all payout notifications for the profile. After saving, use the **Send test** button to validate your endpoint before a real payout depends on it (see [Testing your endpoint](#testing-your-endpoint)). Each payout's delivery history is available from its **Webhook activity** view (see [Delivery logs](#delivery-logs)). --- ## Webhook Fields All values are delivered as strings. - **`id`** (`string`) (required) - Example: `afe11bea-768b-47ae-ba0f-907379fbe5ef`: Payout UUID. Stable across retries and across the `done`/`error` states. **Important:** Use the `id` together with `status` for idempotency. The same payout/status can arrive more than once if a retry fires after you already processed it. - **`status`** (`string`) (required) - Example: `done`: Machine-readable status. `done` when the payout is confirmed, `error` when it failed. - **`display_status`** (`string`) (required) - Example: `Done`: Human-readable status label, suitable for display. - **`total_requested`** (`string`) (required) - Example: `0.5`: Amount requested, denominated in `coin`. - **`total_requested_fiat`** (`string`) (required) - Example: `32150.00`: `total_requested` converted to your fiat currency. - **`total_with_fee`** (`string`) (required) - Example: `0.5005`: Requested amount plus fee, denominated in `coin`. - **`total_with_fee_fiat`** (`string`) (required) - Example: `32182.15`: `total_with_fee` converted to your fiat currency. - **`error`** (`string`) (required): Error text when the payout failed (`status=error`). Empty otherwise. - **`blockchain_fee`** (`string`) (required) - Example: `0.0005`: Network fee paid to the blockchain, denominated in `coin`. - **`fee`** (`string`) (required) - Example: `0`: BlockBee processing fee, denominated in `coin`. - **`coin`** (`string`) (required) - Example: `btc`: Coin/ticker for the payout (e.g. `btc`, `ltc`, `trc20_usdt`). - **`timestamp`** (`string`) (required) - Example: `08/06/2026 14:22:01`: Payout timestamp, formatted `dd/mm/YYYY HH:MM:SS`. ### Example Payload ```json { "id": "afe11bea-768b-47ae-ba0f-907379fbe5ef", "status": "done", "display_status": "Done", "total_requested": "0.5", "total_requested_fiat": "32150.00", "total_with_fee": "0.5005", "total_with_fee_fiat": "32182.15", "error": "", "blockchain_fee": "0.0005", "fee": "0", "coin": "btc", "timestamp": "08/06/2026 14:22:01" } ``` --- ## Security: Verify Webhook Signatures > **WARNING** >**Security Warning!** Always verify incoming webhook signatures. For a complete guide, see our **[Verify Webhook Signatures Guide](/webhooks/verify-webhook-signature)**. --- ## Acknowledging and Retries Respond with **HTTP 200** to acknowledge the webhook. The payout webhook keys off the status code only; the response body is not inspected. Keep your response small and return it quickly, since slow or oversized responses are treated as failures. Any non-200 status, timeout, or connection error is treated as a failure, and BlockBee retries with exponential backoff of `6 × 2^(attempt-1)` minutes: ``` 6m → 12m → 24m → 48m → 96m → 192m → 384m → 768m → 1536m → 3072m ``` After **11 attempts** (~3–4 days), BlockBee stops retrying. --- ## Implementation Examples The payout webhook delivers form-encoded fields, so read them from the parsed form (`POST`) or query string (`GET`), not from a JSON body. Signature verification is delegated to a `verifyWebhookSignature` helper; see the [Verify Webhook Signatures Guide](/webhooks/verify-webhook-signature). ```javascript // Express.js webhook handler // Accept both POST (form body) and GET (query string). app.all('/payout-webhook', express.urlencoded({ extended: true }), (req, res) => { // 1. Verify the webhook signature if (!verifyWebhookSignature(req)) { return res.status(403).send('Invalid signature'); } // 2. Extract the fields const fields = req.method === 'POST' ? req.body : req.query; const { id, status, error, total_requested, coin } = fields; // 3. Ignore test sends (all-zero sentinel id) if (id === '00000000-0000-0000-0000-000000000000') { return res.status(200).send('ok'); } // 4. Idempotency: de-dupe on (id, status) if (isPayoutUpdateProcessed(id, status)) { return res.status(200).send('ok'); } // 5. Handle both terminal states if (status === 'done') { markPayoutComplete({ id, amount: total_requested, coin }); } else if (status === 'error') { markPayoutFailed({ id, reason: error }); } // 6. Record that this (id, status) was processed markPayoutUpdateProcessed(id, status); // MUST return 200 to acknowledge res.status(200).send('ok'); }); ``` ```php ``` ```python # Flask webhook handler @app.route('/payout-webhook', methods=['GET', 'POST']) def payout_webhook(): # 1. Verify the webhook signature if not verify_webhook_signature(request): return 'Invalid signature', 403 # 2. Extract the fields (form for POST, query string for GET) fields = request.form if request.method == 'POST' else request.args payout_id = fields.get('id') status = fields.get('status') error = fields.get('error') total_requested = fields.get('total_requested') coin = fields.get('coin') # 3. Ignore test sends (all-zero sentinel id) if payout_id == '00000000-0000-0000-0000-000000000000': return 'ok', 200 # 4. Idempotency: de-dupe on (id, status) if is_payout_update_processed(payout_id, status): return 'ok', 200 # 5. Handle both terminal states if status == 'done': mark_payout_complete(payout_id, total_requested, coin) elif status == 'error': mark_payout_failed(payout_id, error) # 6. Record that this (id, status) was processed mark_payout_update_processed(payout_id, status) # MUST return 200 to acknowledge return 'ok', 200 ``` ```ruby # Ruby on Rails webhook handler class PayoutWebhooksController < ApplicationController skip_before_action :verify_authenticity_token def webhook # 1. Verify the webhook signature unless verify_webhook_signature(request) render plain: 'Invalid signature', status: :forbidden return end # 2. params merges form body and query string id = params[:id] status = params[:status] error = params[:error] total_requested = params[:total_requested] coin = params[:coin] # 3. Ignore test sends (all-zero sentinel id) if id == '00000000-0000-0000-0000-000000000000' render plain: 'ok', status: :ok return end # 4. Idempotency: de-dupe on (id, status) if payout_update_processed?(id, status) render plain: 'ok', status: :ok return end # 5. Handle both terminal states if status == 'done' mark_payout_complete(id, total_requested, coin) elsif status == 'error' mark_payout_failed(id, error) end # 6. Record that this (id, status) was processed mark_payout_update_processed(id, status) # MUST return 200 to acknowledge render plain: 'ok', status: :ok end end ``` ```csharp // ASP.NET Core webhook handler [ApiController] [Route("payout-webhook")] public class PayoutWebhookController : ControllerBase { [HttpGet] [HttpPost] public IActionResult PayoutWebhook() { // 1. Verify the webhook signature if (!VerifyWebhookSignature(Request)) { return StatusCode(403, "Invalid signature"); } // 2. Extract the fields (form for POST, query string for GET) var fields = Request.Method == "POST" ? (IEnumerable>)Request.Form : Request.Query; var map = fields.ToDictionary(kv => kv.Key, kv => kv.Value.ToString()); var id = map.GetValueOrDefault("id"); var status = map.GetValueOrDefault("status"); var error = map.GetValueOrDefault("error"); var totalRequested = map.GetValueOrDefault("total_requested"); var coin = map.GetValueOrDefault("coin"); // 3. Ignore test sends (all-zero sentinel id) if (id == "00000000-0000-0000-0000-000000000000") { return Ok("ok"); } // 4. Idempotency: de-dupe on (id, status) if (IsPayoutUpdateProcessed(id, status)) { return Ok("ok"); } // 5. Handle both terminal states if (status == "done") { MarkPayoutComplete(id, totalRequested, coin); } else if (status == "error") { MarkPayoutFailed(id, error); } // 6. Record that this (id, status) was processed MarkPayoutUpdateProcessed(id, status); // MUST return 200 to acknowledge return Ok("ok"); } } ``` ```java // Spring Boot webhook handler @RestController @RequestMapping("/payout-webhook") public class PayoutWebhookController { @RequestMapping(method = { RequestMethod.GET, RequestMethod.POST }) public ResponseEntity payoutWebhook(HttpServletRequest request) { // 1. Verify the webhook signature if (!verifyWebhookSignature(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid signature"); } // 2. getParameter reads both form body and query string String id = request.getParameter("id"); String status = request.getParameter("status"); String error = request.getParameter("error"); String totalRequested = request.getParameter("total_requested"); String coin = request.getParameter("coin"); // 3. Ignore test sends (all-zero sentinel id) if ("00000000-0000-0000-0000-000000000000".equals(id)) { return ResponseEntity.ok("ok"); } // 4. Idempotency: de-dupe on (id, status) if (isPayoutUpdateProcessed(id, status)) { return ResponseEntity.ok("ok"); } // 5. Handle both terminal states if ("done".equals(status)) { markPayoutComplete(id, totalRequested, coin); } else if ("error".equals(status)) { markPayoutFailed(id, error); } // 6. Record that this (id, status) was processed markPayoutUpdateProcessed(id, status); // MUST return 200 to acknowledge return ResponseEntity.ok("ok"); } } ``` ```go // Go webhook handler package main import ( "net/http" ) func payoutWebhookHandler(w http.ResponseWriter, r *http.Request) { // 1. Verify the webhook signature if !verifyWebhookSignature(r) { http.Error(w, "Invalid signature", http.StatusForbidden) return } // 2. ParseForm populates r.Form from both the body and the query string if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } id := r.Form.Get("id") status := r.Form.Get("status") errText := r.Form.Get("error") totalRequested := r.Form.Get("total_requested") coin := r.Form.Get("coin") // 3. Ignore test sends (all-zero sentinel id) if id == "00000000-0000-0000-0000-000000000000" { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) return } // 4. Idempotency: de-dupe on (id, status) if isPayoutUpdateProcessed(id, status) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) return } // 5. Handle both terminal states if status == "done" { markPayoutComplete(id, totalRequested, coin) } else if status == "error" { markPayoutFailed(id, errText) } // 6. Record that this (id, status) was processed markPayoutUpdateProcessed(id, status) // MUST return 200 to acknowledge w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) } ``` ```bash # Test a POST payout webhook locally with curl (form-encoded body) curl -X POST http://localhost:3000/payout-webhook \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "id=afe11bea-768b-47ae-ba0f-907379fbe5ef" \ --data-urlencode "status=done" \ --data-urlencode "display_status=Done" \ --data-urlencode "total_requested=0.5" \ --data-urlencode "total_requested_fiat=32150.00" \ --data-urlencode "total_with_fee=0.5005" \ --data-urlencode "total_with_fee_fiat=32182.15" \ --data-urlencode "error=" \ --data-urlencode "blockchain_fee=0.0005" \ --data-urlencode "fee=0" \ --data-urlencode "coin=btc" \ --data-urlencode "timestamp=08/06/2026 14:22:01" # Expected response: HTTP 200 ``` ### Key Checks 1. **Verify the signature** - Confirm `x-ca-signature` against the raw bytes before acting. 2. **Recognize test sends** - Treat the all-zero `id` as a no-op (see [Testing your endpoint](#testing-your-endpoint)). 3. **De-dupe on (`id`, `status`)** - The same terminal update can arrive more than once. 4. **Handle both states** - Process `done` and `error`; the failure reason is in `error`. --- ## Testing Your Endpoint Once a webhook URL is saved, a **Send test** button appears on the payout settings page. It fires a single, synchronous webhook at your configured URL so you can validate your endpoint, including signature verification, before any real payout depends on it. The panel previews the exact payload it will send and reports the result inline. The test request is **identical to a real one**: same method (`GET`/`POST`), same `x-ca-signature` signing, same payload shape, with two differences: - It is a one-off probe. It creates no payout record, writes no delivery log, and is never retried. - The payload carries a **sentinel `id` of all zeros** so you can recognize a test and avoid acting on it. ```json { "id": "00000000-0000-0000-0000-000000000000", "status": "done", "display_status": "Done", "total_requested": "1", "total_requested_fiat": "50.00", "total_with_fee": "1.001", "total_with_fee_fiat": "50.05", "error": "", "blockchain_fee": "0.001", "fee": "0", "coin": "btc", "timestamp": "08/06/2026 14:22:01" } ``` The button reports the result back: HTTP **200** means your endpoint accepted the test; any other value (including a transport failure such as a timeout or blocked connection) means it didn't. Tests are rate-limited to **one per minute** and protected by a CAPTCHA. > **TIP** >**Recommended:** Have your handler treat the all-zero `id` as a no-op: verify the signature, return `200`, but don't create or modify any payout record. Test sends then never touch real data. --- ## Delivery Logs BlockBee records every delivery attempt for each payout. In your dashboard, open any payout and select **Webhook activity** (next to *Payout information*) to review its delivery history: the URL each attempt was sent to, the response received, the resulting status, and when it was sent. Use it to confirm a payout webhook was delivered, or to debug one that wasn't. --- ## Reliability and Best Practices - **Verify signatures.** Always check `x-ca-signature` against the raw request before trusting the payload. - **Be idempotent.** De-dupe on (`id`, `status`); a retry can fire after you already processed the update but the `200` didn't reach BlockBee. - **Return 200 fast.** Do heavy processing asynchronously. The connection has a short timeout, and over-large or slow responses count as failures. - **Expect both states.** You receive a webhook for the terminal `error` state too, with `error` populated. - **Use a public, SSRF-safe URL.** Internal and loopback addresses are rejected, including on redirects. For BlockBee's general webhook delivery, retry, and timeout behavior, see the **[How Webhooks Overview](/webhooks)**. ## Webhook Payload Example ```json { "id": "afe11bea-768b-47ae-ba0f-907379fbe5ef", "status": "done", "display_status": "Done", "total_requested": "0.5", "total_requested_fiat": "32150.00", "total_with_fee": "0.5005", "total_with_fee_fiat": "32182.15", "error": "", "blockchain_fee": "0.0005", "fee": "0", "coin": "btc", "timestamp": "08/06/2026 14:22:01" } ```