Skip to main content

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:

EventWhen it is sentTypical use
payment.detectedA matching transaction was seen before the confirmation threshold was reached.Show "payment seen" or "waiting for confirmations".
payment.confirmedThe transaction reached required_confirmations.Mark the order as paid or ready for fulfillment.

A typical checkout flow looks like this:

  1. Your backend creates a payment with POST /v1/btc/payments.
  2. Manatee returns a payment ID, receiving address, amount, expiry time, and status.
  3. Your customer sends Bitcoin to the returned address.
  4. Manatee detects the transaction and queues a payment.detected webhook.
  5. After the configured confirmation threshold is reached, Manatee queues a payment.confirmed webhook.
  6. 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:

HeaderPurpose
X-Event-IDUnique event delivery ID. Store it for idempotency.
X-Event-TypeEvent type, such as payment.detected or payment.confirmed.
X-SignatureHMAC-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.

AttemptDelay before retry
11 minute
25 minutes
330 minutes
42 hours
56 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 caseWhat happensRecommended handling
Endpoint timeout or network errorManatee records the failed attempt and retries later.Keep the handler small and return quickly after durable storage.
Non-2xx responseThe delivery is treated as failed.Return 2xx only after signature verification and event acceptance.
Duplicate deliveryThe same X-Event-ID can be received again.Store processed event IDs and ignore repeats.
Out-of-order assumptionspayment.detected and payment.confirmed are separate events.Update orders based on the payment status and event type, not on request arrival timing alone.
Signature mismatchThe 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 routeManatee 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:

  1. Read the raw request body.
  2. Verify X-Signature with the correct webhook signing secret.
  3. Check whether X-Event-ID was already processed.
  4. Store the event ID and payload in a transaction.
  5. Find your order using the stored payment_id -> order_id mapping.
  6. Apply the state change only if it is still valid.
  7. 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.