Webhooks Best Practices
Webhooks enable BareCommerce to receive real-time payment confirmations from payment providers. Understand the architecture and best practices to integrate safely.
Architecture: How Webhooks Work
The Complete Flow
┌────────────────────────────────────┐
│ Frontend (Your Storefront) │
│ ├─ Create order in BareCommerce │
│ ├─ Get order.id │
│ ├─ Create payment with order.id │
│ └─ Customer confirms payment │
└────────┬───────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Payment Provider │
│ (Stripe/PayPal/Square) │
│ ├─ Process payment │
│ ├─ Store order.id in transaction │
│ └─ Send webhook to BareCommerce │
└────────┬───────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ BareCommerce Webhook Handler │
│ ├─ Receive webhook │
│ ├─ Verify signature │
│ ├─ Extract order.id from data │
│ └─ Find & update order │
└────────┬───────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Database │
│ ├─ Order status: pending → paid │
│ └─ Stock: decrement │
└────────────────────────────────────┘Key Points
- Frontend is the ONLY thing talking to payment providers for payment initiation
- BareCommerce only receives webhooks - never initiates payment provider calls
- Payment provider sends webhook with order_id - enables BareCommerce to find the order
- Stock is decremented inside webhook handler - when payment is confirmed
- All operations are atomic - order status + stock update together or not at all
Setting Up Webhooks
1. Stripe Webhook Setup
In your Stripe Dashboard:
- Go to Developers → Webhooks
- Click Add an endpoint
- Endpoint URL:
https://yourdomain.com/api/webhooks/stripe - Select events:
payment_intent.succeededpayment_intent.payment_failedcharge.refundedcharge.refund.updatedcheckout.session.completed(if using Hosted Checkout)
- Click Add endpoint
- Click your endpoint and copy the Signing secret
- Set environment variable:
STRIPE_WEBHOOK_SECRET=whsec_...
Test webhook in Stripe Dashboard:
- Scroll to your endpoint
- Click Send test event → select event type → send
- Check your logs to verify receipt
2. PayPal Webhook Setup
In PayPal Developer Dashboard:
- Go to My Apps & Credentials
- Select your app
- Click Webhooks
- Click Create Webhook
- Webhook URL:
https://yourdomain.com/api/webhooks/paypal - Select events:
PAYMENT.CAPTURE.COMPLETEDPAYMENT.CAPTURE.DENIEDPAYMENT.CAPTURE.REFUNDED
- Click Create Webhook
- Copy the Webhook ID
- Set environment variables:
PAYPAL_WEBHOOK_ID=your_webhook_id PAYPAL_CLIENT_ID=your_client_id PAYPAL_CLIENT_SECRET=your_client_secret PAYPAL_MODE=sandbox (or live)
3. Square Webhook Setup
In Square Developer Dashboard:
- Go to Webhooks
- Click Add Endpoint
- Endpoint URL:
https://yourdomain.com/api/webhooks/square - Select events:
payment.completedpayment.failedrefund.createdrefund.updated
- Click Add
- Click your endpoint and copy Signature Key
- Set environment variable:
SQUARE_WEBHOOK_SIGNATURE_KEY=...
Receiving Webhooks
Payment Provider Sends Webhook
When payment succeeds, the provider sends a POST request to your webhook URL:
Stripe Example:
POST /api/webhooks/stripe HTTP/1.1
stripe-signature: t=1234567890,v1=abcd1234...
{
"id": "evt_1234567890",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_1234567890",
"metadata": {
"order_id": "ord_xyz789" ← THE KEY LINK
},
"amount_received": 12877,
"currency": "usd"
}
}
}Your webhook handler receives this and:
- Extracts raw payload
- Verifies signature (prevents tampering)
- Parses JSON
- Extracts order_id from metadata/custom_id/reference_id
- Finds the order in BareCommerce
- Updates order status to "paid"
- Stock is decremented atomically
Signature Verification
Why Verify?
Signature verification proves the webhook came from your payment provider, not an attacker.
How It Works
Stripe (HMAC-SHA256):
// Webhook header: stripe-signature: t=1234567890,v1=signature
// You compute: HMAC-SHA256(secret, "1234567890.payload")
// Compare with: v1 value using constant-time comparison
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(providedSignature)
);PayPal (OAuth verification):
// Call PayPal API to verify signature
const response = await fetch(
'https://api-m.paypal.com/v1/notifications/verify-webhook-signature',
{
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({
transmission_id: req.headers['paypal-transmission-id'],
transmission_time: req.headers['paypal-transmission-time'],
cert_url: req.headers['paypal-cert-url'],
auth_algo: req.headers['paypal-auth-algo'],
transmission_sig: req.headers['paypal-transmission-sig'],
webhook_id: WEBHOOK_ID,
webhook_event: body
})
}
);
const verified = response.verification_status === 'SUCCESS';Square (HMAC-SHA256):
// Signature header: x-square-hmacsha256-signature
// You compute: HMAC-SHA256(signature_key, "notification_url + payload")
const signature = crypto
.createHmac('sha256', signatureKey)
.update(notificationUrl + payload)
.digest('base64');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(providedSignature)
);Best Practices
✓ Do:
- Always verify signatures
- Use constant-time comparison (protects against timing attacks)
- Validate timestamp (Stripe: max 5 min old)
- Return 200 if signature invalid (don't retry)
✗ Don't:
- Skip signature verification
- Log signatures
- Use string equality (not constant-time)
Finding the Order
The Order ID Link
Your frontend must pass the order_id to the payment provider when creating the payment:
Stripe:
const paymentIntent = await stripe.paymentIntents.create({
metadata: { order_id: 'ord_xyz789' } // ← Required
});PayPal:
const order = await paypal.orders.create({
purchase_units: [{
custom_id: 'ord_xyz789' // ← Required
}]
});Square:
const payment = await square.paymentsApi.createPayment({
referenceId: 'ord_xyz789' // ← Required
});Webhook Handler Extracts It
When webhook arrives:
Stripe:
const orderId = event.data.object.metadata?.order_id;
const order = await findOrderById(orderId);PayPal:
const orderId = event.resource.custom_id;
const order = await findOrderById(orderId);Square:
const orderId = event.data.object.payment.reference_id;
const order = await findOrderById(orderId);Handling Payment Confirmation
When Webhook Arrives
export async function handlePaymentSuccess(orderId, storeId, data) {
// Called by webhook handler
const result = await updateOrder(orderId, storeId, {
status: 'paid',
paymentProvider: data.provider, // stripe, paypal, square
paymentId: data.paymentId, // Transaction ID
paymentStatus: 'succeeded',
paymentMethod: data.paymentMethod, // card, paypal, etc.
paymentDetails: data.paymentDetails // JSON object
});
if (!result.success) {
// Log error, alert merchant, etc.
console.error(`Payment confirmation failed: ${result.message}`);
throw new Error(result.message);
}
// Return 200 to payment provider
return { received: true };
}Inside updateOrder Transaction
// 1. Validate transition: pending → paid ✓
// 2. Check write-once fields (all null) ✓
// 3. Re-check stock availability
// 4. ATOMIC TRANSACTION:
// - Decrement stock for each tracked item
// - Update order status to "paid"
// - Set payment fields
// - Set paidAt timestamp
// 5. Commit (or rollback if any check fails)Error Scenarios
1. Invalid Signature
Cause: Wrong secret, tampered payload, replay attack
Handler Response: Return 400 Bad Request
Provider Response: Will retry webhook later
Impact: Order stays pending (safe)
if (!isValid) {
console.error('Invalid webhook signature');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}2. Order Not Found
Cause: order_id not passed to payment provider, order deleted, ID mismatch
Handler Response: Return 200 OK with warning (don't retry)
Provider Response: No retry (200 status means success)
Impact: Payment received but not linked to order - manual reconciliation needed
const order = await findOrderById(orderId);
if (!order) {
console.warn(`No order found for payment ${paymentId}`);
return NextResponse.json({
received: true,
warning: 'Order not found'
});
}Prevention:
- ALWAYS pass order_id to payment provider
- Double-check the ID format matches
- Test end-to-end before going live
3. Insufficient Stock at Payment
Cause: Stock sold out between order creation and payment confirmation
Handler Response: Return 500 Internal Server Error (triggers retry)
Provider Response: Will retry webhook (expects eventual success)
Impact: Payment received, order can't be fulfilled - requires manual refund
const result = await updateOrder(orderId, storeId, {
status: 'paid',
paymentProvider, paymentId, paymentMethod
});
if (!result.success && result.code === 'INSUFFICIENT_STOCK') {
// Payment received but can't fulfill
console.error(`Stock unavailable for paid order ${orderId}`);
// Queue automatic refund or alert merchant
await queueRefundJob(orderId);
return NextResponse.json(
{ error: 'Stock unavailable, refund queued' },
{ status: 500 }
);
}Prevention:
- Stock is re-checked at payment confirmation
- If stock is tight, use stock reservation at order creation
- Monitor orders with stock issues
4. Database Error
Cause: Connection lost, deadlock, constraint violation
Handler Response: Return 500 Internal Server Error
Provider Response: Will retry webhook
Impact: Order stays pending until retry succeeds
try {
const result = await updateOrder(...);
} catch (error) {
console.error('Database error processing webhook:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}5. Duplicate Webhook
Cause: Provider retried, network timeout, duplicate event
Handler Response: Return 200 OK (idempotent)
Provider Response: No retry (200 status)
Impact: None - idempotency handles it
// First webhook:
// Order status: pending → paid ✓
// Stock decremented ✓
// Webhook returns 200
// Second webhook (duplicate):
// Order status: paid (no change needed)
// Check: input.status === 'paid' but order.status === 'paid'
// No transition triggered
// Webhook returns 200 (idempotent)Idempotency Guarantees
How BareCommerce Prevents Double-Processing
Layer 1: Status Transition Validation
// First webhook: pending → paid ✓ (valid)
// Second webhook: paid → paid ✗ (no transition)
if (input.status && input.status !== order.status) {
// Only executes if status is actually changing
}Layer 2: Write-Once Fields
// First webhook sets: paymentId = "pi_123"
// Second webhook tries: paymentId = "pi_123"
// Check: currentValue === inputValue? If equal, no-op
// Or: currentValue !== null && inputValue !== null? ErrorLayer 3: Database Transaction
// Two webhooks arrive simultaneously
// Transaction 1: Reads order (pending)
// Transaction 2: Reads order (pending)
// Transaction 1: Commits first (pending → paid)
// Transaction 2: Retries, order is now paid
// Input.status (paid) === order.status (paid)
// No transition, no-opTesting Webhooks
1. Use Provider's Test Mode
Stripe:
// In Stripe Dashboard → Developers → Webhooks
// Click your endpoint → Send test event
// Select event type (payment_intent.succeeded)
// Click Send test eventPayPal:
// In PayPal Dashboard → Webhooks
// Click your webhook → Send test notification
// Select event type
// Click SendSquare:
// In Square Dashboard → Webhooks
// Click your endpoint → Send test notification
// Check your server logs2. Local Testing with ngrok
Test locally without deploying:
# Terminal 1: Start ngrok tunnel
ngrok http 3000
# Copy the forwarding URL: https://abc123.ngrok.io
# Terminal 2: Start your server
npm run dev
# In Stripe/PayPal/Square Dashboard:
# Set webhook URL: https://abc123.ngrok.io/api/webhooks/stripe
# Send test webhook from dashboard
# Watch your local server logs3. Create a Test Order End-to-End
// 1. Create order
const order = await fetch('/api/stores/{storeId}/orders', {
method: 'POST',
body: JSON.stringify({
customerId: 'test_customer',
items: [{ productId: 'test_prod', quantity: 1 }],
total: '9.99'
})
});
const { id: orderId } = await order.json();
// 2. Create payment with test card
const paymentIntent = await stripe.paymentIntents.create({
amount: 999,
currency: 'usd',
payment_method: 'pm_card_visa', // Test card
metadata: { order_id: orderId },
confirm: true
});
// 3. Verify webhook was received
// Check logs: "Payment confirmed for order {orderId}"
// 4. Verify order status changed
const updated = await fetch(`/api/stores/{storeId}/orders/${orderId}`);
console.log(await updated.json()); // status should be "paid"Retry Logic
Payment providers retry webhooks if they don't receive a 2xx response:
| Provider | Retry Schedule | Max Attempts |
|---|---|---|
| Stripe | Exponential backoff up to 3 days | ~20 |
| PayPal | 15 retries over 3 days | 15 |
| Square | Immediate, then 1/hour for 24h | Variable |
Guidelines:
- Return
200if you successfully processed the webhook - Return
200if you couldn't find the order (don't retry) - Return
400if signature is invalid (don't retry) - Return
500for errors (do retry)
Monitoring & Alerting
What to Monitor
// Log every webhook received
console.log(`[Webhook] ${provider}:${event_type} (${event_id})`);
// Log processing results
console.log(`[Webhook] Order ${orderId} → status: ${order.status}, stock: ${stock}`);
// Alert on errors
if (!result.success) {
await sendAlert(`Webhook processing failed: ${result.error}`);
}Webhook Delivery Log
BareCommerce logs all webhook deliveries. Check:
- Delivery status (success/failure)
- Response code
- Latency
- Error messages
Use this to debug issues.
Missing Webhooks
If an order never transitions to "paid":
- Check webhook URL - Is it correct in provider dashboard?
- Check firewall/security - Can provider reach your domain?
- Check logs - Did webhook arrive? Was signature valid?
- Check database - Is order still pending? Does it have paymentId?
- Reconcile with provider - Check provider's webhook logs
Security Best Practices
✓ Do:
- Verify signatures ALWAYS
- Use HTTPS (not HTTP)
- Store secrets in environment variables
- Log webhook events (without sensitive data)
- Handle errors gracefully
- Process webhooks idempotently
- Monitor for suspicious activity
- Rotate secrets regularly
✗ Don't:
- Log full payloads
- Log payment methods or card data
- Hardcode secrets in code
- Skip signature verification
- Trust webhook data for critical operations
- Store payment provider secrets in database
- Expose webhook URLs publicly
Related Guides
- Orders API - Order creation and management
- Stripe Integration - Stripe-specific setup and examples
- PayPal Integration - PayPal-specific setup and examples
- Square Integration - Square-specific setup and examples
- Error Handling - How to handle errors properly