Webhook Delivery in a Bitcoin Payment API
Webhook delivery is the part of a Bitcoin payment integration where a network event becomes a product decision. A customer sends Bitcoin to an address, Manatee detects the transaction, and your backend receives an HTTP event that can update an order.
The important design choice is that webhook delivery is at-least-once. Your endpoint may receive the same event more than once, and it should still produce one business outcome.
Delivery flow
For on-chain Bitcoin payments, Manatee sends webhook events at the two status changes most integrations care about:
| Event | When it is sent | Typical use |
|---|---|---|
payment.detected | A matching transaction was seen before the confirmation threshold was reached. | Show "payment seen" or "waiting for confirmations". |
payment.confirmed | The transaction reached required_confirmations. | Mark the order as paid or ready for fulfillment. |
A typical checkout flow looks like this:
- Your backend creates a payment with
POST /v1/btc/payments. - Manatee returns a payment ID, receiving address, amount, expiry time, and status.
- Your customer sends Bitcoin to the returned address.
- Manatee detects the transaction and queues a
payment.detectedwebhook. - After the configured confirmation threshold is reached, Manatee queues a
payment.confirmedwebhook. - Your backend verifies the signature, stores the event ID, and updates the matching order.
Use payment.confirmed as the fulfillment trigger for most production flows. Treat payment.detected as an early signal, not final settlement.
Example payload
Webhook payloads contain the Manatee payment ID and Bitcoin transaction data. They do not include your original reference, so store the returned payment ID next to your own order ID when you create the payment.
{
"version": "1",
"type": "payment.confirmed",
"data": {
"payment_id": "pay_abc123",
"txid": "a1b2c3d4...",
"address": "tb1q...",
"amount_sats": 10000,
"received_sats": 10000,
"confirmations": 1,
"webhook_url": "https://your-public-url.example/webhooks/btc"
}
}
Each webhook request also includes headers your backend should read before changing order state:
| Header | Purpose |
|---|---|
X-Event-ID | Unique event delivery ID. Store it for idempotency. |
X-Event-Type | Event type, such as payment.detected or payment.confirmed. |
X-Signature | HMAC-SHA256 signature of the raw request body. |
Retry behavior
Return a 2xx response only after your application has accepted the event. If your endpoint returns a non-2xx response or cannot be reached, Manatee retries delivery.
| Attempt | Delay before retry |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6+ | 24 hours |
Webhook delivery is attempted up to 10 times. The practical consequence is simple: your handler should be quick, idempotent, and able to recover from temporary outages.
In normal operation, payment.detected is queued shortly after the transaction appears in the mempool. The internal webhook queue is checked about every 500 ms, so successful webhook deliveries usually arrive within a few seconds after detection or confirmation.
Failure cases to handle
Webhook endpoints fail in ordinary ways. Design the handler so these cases are boring:
| Failure case | What happens | Recommended handling |
|---|---|---|
| Endpoint timeout or network error | Manatee records the failed attempt and retries later. | Keep the handler small and return quickly after durable storage. |
| Non-2xx response | The delivery is treated as failed. | Return 2xx only after signature verification and event acceptance. |
| Duplicate delivery | The same X-Event-ID can be received again. | Store processed event IDs and ignore repeats. |
| Out-of-order assumptions | payment.detected and payment.confirmed are separate events. | Update orders based on the payment status and event type, not on request arrival timing alone. |
| Signature mismatch | The payload may be forged, modified, or verified with the wrong secret. | Reject the request and do not update an order. |
| Browser or bot protection blocks the route | Manatee cannot complete server-to-server delivery. | Bypass WAF, captcha, and login checks only for the webhook path; rely on HMAC verification there. |
Signature verification
Always verify X-Signature before trusting the payload. The signature is calculated over the raw request body, not over a parsed and re-serialized JSON object.
const crypto = require('crypto');
function verifySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader?.startsWith('sha256=')) {
return false;
}
const actual = Buffer.from(signatureHeader.slice('sha256='.length), 'hex');
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest();
if (actual.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(actual, expected);
}
Use the webhook signing secret for the API key that created the payment. Each API key has its own secret, and dashboard rotation can be used if the secret is exposed.
Idempotent handler shape
A robust webhook handler usually does this:
- Read the raw request body.
- Verify
X-Signaturewith the correct webhook signing secret. - Check whether
X-Event-IDwas already processed. - Store the event ID and payload in a transaction.
- Find your order using the stored
payment_id -> order_idmapping. - Apply the state change only if it is still valid.
- Return
2xx.
For example, if an order is already marked paid, a repeated payment.confirmed event should not create a second fulfillment, invoice, license, or shipment.
Testing delivery
Before sending Bitcoin, use the test webhook endpoint to verify that your route is reachable and that your raw-body signature code works:
curl -X POST "$MANATEE_API_URL/v1/webhooks/test" \
-H "Authorization: Bearer $MANATEE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "'"$WEBHOOK_URL"'"
}'
Then run a full testnet payment from the Quickstart and confirm that your backend handles both payment.detected and payment.confirmed.
Related docs
- Quickstart for the full testnet payment flow.
- Payment Status Lifecycle for status and confirmation semantics.
- Verify Webhook Signatures for HMAC verification details.