BTCPay webhooks: recover without duplicate fulfillment
Check delivery history, authenticate the raw request, reread invoice state and recover through one durable fulfillment path. A replay is not a new order.
A customer paid, but your application did not deliver. A missing webhook is one possible cause, not the conclusion. Start by checking invoice settlement and the order’s actual delivery state separately. Then use webhook history to investigate the message path.
This is a free recovery guide. It does not perform a replay or connect to your wallet. Test recovery in an isolated environment before using it on a live order. Never share API keys, webhook secrets, seed phrases or private keys.
Use the right permissions for each check
Invoice reads require btcpay.store.canviewinvoices. The webhook controller in the pinned source requires btcpay.store.webhooks.canmodifywebhooks, including for its delivery-history routes. Those are different permissions. A key that can inspect webhooks is not automatically allowed to read invoices.
Prefer separate store-scoped credentials when that fits your access policy. The webhook permission is modification-capable, so do not describe it as a read-only grant. Use existing authorized access and avoid giving support staff broader rights just to run this checklist.
Inspect delivery history without making an absence claim
For the intended store, use GET /api/v1/stores/{storeId} followed by /webhooks/{webhookId}/deliveries. Start with count=50. Read each record’s id, timestamp, deliveryTime, httpCode, status and errorMessage. Then inspect the stored request to match its storeId and invoiceId, rather than treating every delivery as the order you are investigating.
An empty or limited history response does not prove an event never fired. You may have selected the wrong store or webhook, requested too few records, encountered retention or pruning, or found an event filter that excluded the event. Confirm webhook identity, enabled state, authorized events and the observation window. Do not invent a retention period; establish what your server actually retains.
If you have a delivery ID, read that one delivery by appending /{deliveryId} to the deliveries route. Its stored body is available by appending /request after the delivery ID. In the pinned controller this returns stored request bytes as application/json; a pruned body returns HTTP 409 with webhookdelivery-pruned.
A successful HTTP response from your handler only shows that the endpoint accepted the request. It does not prove durable enqueueing or usable fulfillment. Match the delivery record with your own application’s durable receipt and completed order result. Protect stored bodies because they can contain buyer or order information.
Know which failures trigger automatic retries
At upstream commit cbee1f8c782f0f3c8fa5457cf4a3a47e92c0e059, automatic redelivery, when enabled, allows eight additional attempts: after ten seconds, one minute, then six waits of ten minutes. This is version-specific source behavior, not a guarantee about your installed release or observed webhook settings.
The retry decision in that source covers HTTP 5xx, 429, 408 and failures with no HTTP code. Most other 4xx responses end that retry cycle. A valid event rejected with 400 may therefore need manual recovery after the cause is fixed. Do not assume a redirect means the final endpoint stored the event.
Check actual delivery outcomes rather than assuming all retries happened. The webhook may have been disabled, its selected event types may have changed, or the retained body may no longer be available.
Authenticate before parsing or acting
For a live incoming webhook, verify BTCPay-Sig against the exact raw request body using the configured webhook secret. BTCPay constructs HMAC-SHA256 over those bytes and sends sha256= followed by the hexadecimal digest. Validate the header format and compare in constant time before acting on parsed JSON. Reformatting or reserializing JSON changes the bytes being checked.
The delivery request endpoint does not return the original BTCPay-Sig header. Downloaded JSON alone cannot verify that original signature. A locally generated signature with a test secret proves only your test path, not an original BTCPay delivery or settlement. Keep local replay in a non-fulfilling test harness, or retain the original raw body and signature header together under an appropriate data policy.
A verified signature authenticates the message; it does not show that your application delivered the order. Use the event as a trigger to reread the matching invoice through an authenticated API read, checking status, additionalStatus and payment details before any fulfillment decision.
Redeliver through the existing message path
After fixing the cause and verifying that the order has not already been fulfilled, the server exposes POST to the specific delivery route with /redeliver appended. This is a write with a possible customer-facing side effect, not another read-only diagnostic. Use it only with the appropriate operational authorization and duplicate-delivery protection in place.
The pinned implementation returns a new delivery ID and queues the attempt. That return value does not prove the replay reached your handler. Check the resulting delivery record and your application’s durable receipt. Pruned deliveries return HTTP 409 and cannot be redelivered from that retained body.
A redelivery receives a new deliveryId, keeps originalDeliveryId pointing to the first attempt, and sets isRedelivery true. Use that linkage to audit attempts. Do not let the new delivery ID create a second logical purchase.
Deduplicate the delivery action, not just the event type
Maintain one durable fulfillment record for the store, invoice or mapped order, and the promised action. All relevant event types, redeliveries and reconciliation jobs must enter that same guard. Deduplicating only by invoiceId plus event type can still fulfill twice when InvoicePaymentSettled and InvoiceSettled both refer to the same order.
Verify the signature, atomically persist or enqueue the event, then acknowledge with 2xx. If durable acceptance fails, do not return a false success. Process delivery behind the shared fulfillment guard and an authenticated invoice check. Settled individual payments, manually Marked invoices and zero-value tests must not become automatic paid-order proof.
Use atomic claims to handle concurrent workers, and pass a stable action idempotency key to downstream delivery systems where supported. Record the completed result. If a process crashes after sending but before recording completion, inspect the actual downstream result before retrying. Without downstream idempotency or recovery evidence, exactly-once fulfillment is not established.
If no retained event can be replayed, reconcile the invoice and order directly. A scheduled reconciliation process can enter the same fulfillment guard without pretending it received an original signed webhook. Keep an audit trail of the evidence and the recovery action. This guide does not add or enable such a process for you.
Sources were checked on October 8, 2026. The controller and sender links below pin the implementation inspected for these details. Confirm the behavior and settings of your installed release before a production replay.
Sources
- BTCPay Server: API integration and recovery guidance ↗
- BTCPay Server: ecommerce and webhook integration ↗
- BTCPay Server: NodeJS raw-body signature example ↗
- Webhook sender: signature, retry and redelivery behavior (pinned source) ↗
- Webhook controller: permissions, delivery bodies and redelivery (pinned source) ↗
- Invoice controller: separate authorization for invoice reads (pinned source) ↗
Educational commentary from Loop XXI. The block height records Bitcoin network time at publication.
Support this research with sats: pay@loopxxi.com · Send a tip.