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

  1. Frontend is the ONLY thing talking to payment providers for payment initiation
  2. BareCommerce only receives webhooks - never initiates payment provider calls
  3. Payment provider sends webhook with order_id - enables BareCommerce to find the order
  4. Stock is decremented inside webhook handler - when payment is confirmed
  5. All operations are atomic - order status + stock update together or not at all

Setting Up Webhooks

1. Stripe Webhook Setup

In your Stripe Dashboard:

  1. Go to DevelopersWebhooks
  2. Click Add an endpoint
  3. Endpoint URL: https://yourdomain.com/api/webhooks/stripe
  4. Select events:
    • payment_intent.succeeded
    • payment_intent.payment_failed
    • charge.refunded
    • charge.refund.updated
    • checkout.session.completed (if using Hosted Checkout)
  5. Click Add endpoint
  6. Click your endpoint and copy the Signing secret
  7. 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:

  1. Go to My Apps & Credentials
  2. Select your app
  3. Click Webhooks
  4. Click Create Webhook
  5. Webhook URL: https://yourdomain.com/api/webhooks/paypal
  6. Select events:
    • PAYMENT.CAPTURE.COMPLETED
    • PAYMENT.CAPTURE.DENIED
    • PAYMENT.CAPTURE.REFUNDED
  7. Click Create Webhook
  8. Copy the Webhook ID
  9. 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:

  1. Go to Webhooks
  2. Click Add Endpoint
  3. Endpoint URL: https://yourdomain.com/api/webhooks/square
  4. Select events:
    • payment.completed
    • payment.failed
    • refund.created
    • refund.updated
  5. Click Add
  6. Click your endpoint and copy Signature Key
  7. 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:

  1. Extracts raw payload
  2. Verifies signature (prevents tampering)
  3. Parses JSON
  4. Extracts order_id from metadata/custom_id/reference_id
  5. Finds the order in BareCommerce
  6. Updates order status to "paid"
  7. 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? Error

Layer 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-op

Testing 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 event

PayPal:

// In PayPal Dashboard → Webhooks
// Click your webhook → Send test notification
// Select event type
// Click Send

Square:

// In Square Dashboard → Webhooks
// Click your endpoint → Send test notification
// Check your server logs

2. 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 logs

3. 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:

ProviderRetry ScheduleMax Attempts
StripeExponential backoff up to 3 days~20
PayPal15 retries over 3 days15
SquareImmediate, then 1/hour for 24hVariable

Guidelines:

  • Return 200 if you successfully processed the webhook
  • Return 200 if you couldn't find the order (don't retry)
  • Return 400 if signature is invalid (don't retry)
  • Return 500 for 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":

  1. Check webhook URL - Is it correct in provider dashboard?
  2. Check firewall/security - Can provider reach your domain?
  3. Check logs - Did webhook arrive? Was signature valid?
  4. Check database - Is order still pending? Does it have paymentId?
  5. 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