Imagine this scenario: a customer pays for your service in Bitcoin. The blockchain confirms the transaction. Your gateway sends a signal to your server. But before you can credit their account, your server crashes, or the network drops the packet, or a hacker spoofs the request. Suddenly, you have a messy ledger, angry customers, and missing funds.
This is the nightmare of every developer building crypto billing systems. The gap between receiving a webhook from a payment provider and writing a permanent record to your internal database ledger is where most integrations fail. It’s not just about catching a success event; it’s about building an architecture that guarantees safety against double-spending, fraud, and system errors.
The Core Problem: Trusting the Signal
When you integrate with a crypto payment processor like Coinbase Commerce or BTCPay Server, you are relying on an external notification system. These services send HTTP POST requests-known as webhooks-to your server when specific events occur, such as a charge being created, pending, or confirmed.
The danger lies in assuming these signals are perfect. They aren't. Networks drop packets. Servers time out. Hackers forge headers. If your code blindly credits a user's balance every time it receives a 'payment successful' message, you will eventually make a mistake. You might credit a user twice if the provider retries a dropped message. Worse, you might credit a fraudulent transaction if you don't verify who sent the message.
To solve this, you need to treat webhooks as untrusted inputs. They are merely transport mechanisms for data, not authoritative commands. The real authority comes from your internal logic, cryptographic verification, and strict database constraints.
Step 1: Verify the Signature Before Processing
The first line of defense is authentication. You must ensure the webhook actually came from the payment provider and wasn't spoofed by a malicious actor trying to inject fake payments into your system.
Most reputable providers, including Coinbase and BTCPay, sign their webhook payloads using HMAC (Hash-based Message Authentication Code). Here is how you handle it:
- Capture the raw body: Do not parse the JSON immediately. You need the exact bytes received to compute the hash. In Node.js, use middleware like
body-parserwith theverifyoption to access the raw buffer. - Extract the signature header: Look for headers like
X-CC-Webhook-Signature(Coinbase) orBTCPAY-SIG(BTCPay). - Recompute the hash: Use the shared secret key provided by the gateway to generate an HMAC SHA-256 hash of the raw body.
- Compare safely: Use a constant-time comparison function to check if your computed hash matches the header. Standard equality checks (
==) can be vulnerable to timing attacks.
If the signatures do not match, reject the request immediately with a 400 or 403 status code. Never process the payload. This step prevents attackers from sending crafted JSON to your endpoint to inflate user balances.
Step 2: Decouple Reception from Logic
A common architectural mistake is processing the business logic directly inside the webhook handler. If your database is slow, or if you need to send emails and provision services, the webhook handler takes too long to respond. The payment provider will timeout and retry the request, potentially causing duplicate processing.
Instead, use a decoupled architecture:
- Receive & Acknowledge: Your webhook endpoint verifies the signature, parses the minimal required data (like the invoice ID), and pushes the event into a message queue (such as RabbitMQ, Kafka, or AWS SQS). It then responds with a 200 OK immediately.
- Queue Buffering: The queue absorbs spikes in traffic and ensures no event is lost even if your processing servers go down temporarily.
- Worker Processing: Background worker processes consume messages from the queue at a controlled rate. They perform the heavy lifting: checking blockchain confirmations, updating ledgers, and triggering downstream actions.
This pattern ensures that your public-facing API remains fast and reliable, while your internal database operations happen independently and safely.
Step 3: Enforce Idempotency in the Database
Idempotency means that performing an action multiple times has the same effect as performing it once. In the context of crediting payments, it means receiving the same 'success' webhook ten times should result in exactly one credit to the user's balance.
You cannot rely on application-level flags alone. You must enforce idempotency at the database level using transactions and unique constraints.
| Column Name | Type | Purpose |
|---|---|---|
| id | UUID | Primary key for the ledger entry |
| invoice_id | VARCHAR | Unique identifier from the payment provider |
| status | ENUM | Current state: pending, credited, failed |
| created_at | TIMESTAMP | When the record was inserted |
Create a unique constraint on the invoice_id. When your worker processes a 'confirmed' webhook, it attempts to insert a new row into the ledger table with that invoice ID. If the row already exists (because the webhook was retried), the database throws a unique violation error. Your code catches this error and simply returns success without doing anything else. This guarantees that no matter how many times the provider retries, the credit happens exactly once.
Step 4: Map Events to Canonical States
Different gateways use different terminology for payment states. Coinbase might say charge:confirmed, while another might say checkout.payment.success. You need a canonical state machine in your application that maps these external events to your internal business logic.
Do not credit users on 'pending' events. A pending event usually means the transaction is seen on the mempool but lacks sufficient blockchain confirmations. It can still be dropped or replaced. Only trigger the final credit when you receive the definitive success event that indicates finality according to your risk tolerance (e.g., 1 confirmation for small amounts, 6 for large ones).
Here is a simple mapping strategy:
- External Event:
charge:created→ Internal Action: Create order record, set status to 'awaiting_payment'. - External Event:
charge:pending→ Internal Action: Update order status to 'processing', notify user. - External Event:
charge:confirmed→ Internal Action: Execute idempotent credit transaction, update order to 'paid', provision service. - External Event:
charge:failedorexpired→ Internal Action: Mark order as 'failed', release any reserved inventory.
Handling Reversals and Refunds
While crypto transactions are generally irreversible on-chain, refunds happen in the business layer. If you issue a refund, you must emit an internal event that reverses the credit. Ensure your ledger supports negative entries or distinct reversal records so your audit trail remains clear. Never delete a credit entry; instead, add a corresponding debit entry linked to the original invoice ID.
Modern Tools Simplifying This Flow
Building this infrastructure from scratch requires significant engineering effort. Modern non-custodial gateways like TxNod are designed with this complexity in mind. By providing HMAC-signed webhooks and robust TypeScript SDKs, they allow developers to focus on their product rather than reinventing secure payment plumbing. For solo founders and indie hackers, using a tool that handles the address derivation and signature verification securely can reduce integration time from weeks to hours, ensuring that the 'webhook to database' path is safe from day one.
Common Pitfalls to Avoid
Even experienced developers stumble on these details:
- Ignoring Retries: Assuming a webhook fires only once. Always design for duplicates.
- Trusting Client-Side Redirects: Never finalize an order based on the user returning to your site after paying. The user could bypass the payment entirely. Always wait for the backend webhook.
- Weak Secret Management: Hardcoding webhook secrets in source code. Use environment variables or a secrets manager.
- Lack of Logging: If you don't log the raw webhook payload and the decision made, debugging a discrepancy later becomes impossible.
What happens if my server misses a webhook?
If your server does not return a 2xx HTTP status code within the timeout window (usually 10-30 seconds), the payment provider will retry the webhook. Most providers implement an exponential backoff strategy, meaning they will try again in 1 minute, then 5 minutes, then 1 hour, etc. This is why idempotency is critical; your system must handle these retries gracefully without duplicating actions.
How do I verify a Coinbase Commerce webhook signature?
You extract the X-CC-Webhook-Signature header from the incoming request. Then, you compute an HMAC SHA-256 hash using your webhook secret and the raw body of the request. Finally, you compare the computed hash with the header value using a constant-time comparison function to prevent timing attacks.
Why is idempotency important for database crediting?
Idempotency ensures that processing the same event multiple times results in the same outcome as processing it once. Without it, a network glitch causing a webhook retry could lead to double-crediting a user's balance, resulting in financial loss and data inconsistency.
Should I credit users immediately upon receiving a 'pending' event?
No. A 'pending' event typically means the transaction is broadcast but not yet confirmed on the blockchain. It can still fail or be replaced. You should only credit users when you receive a 'confirmed' or 'success' event, indicating the transaction has met your required number of block confirmations.
Can I use a message queue for webhook processing?
Yes, and it is highly recommended. Using a message queue decouples the fast-response requirement of the webhook endpoint from the slower, more complex business logic. This improves reliability and allows you to scale processing independently.