How Webhooks Work

Webhooks are HTTP callbacks. When an event occurs in a provider system (payment completed, form submitted, code pushed), the provider sends an HTTP POST request to a URL you have registered.

1. You register: "Send events to https://myapp.com/webhooks/stripe"
2. Event occurs in Stripe: payment_intent.succeeded
3. Stripe sends POST to https://myapp.com/webhooks/stripe
   Body: { "type": "payment_intent.succeeded", "data": { ... } }
4. Your server processes the event and returns 200 OK
5. Stripe marks the webhook as delivered

Common Webhook Providers

ProviderEventsSignature Method
StripePayments, subscriptionsHMAC-SHA256 with Stripe-Signature header
GitHubPush, PR, issuesHMAC-SHA256 with X-Hub-Signature-256 header
SlackMessages, interactionsHMAC-SHA256 with X-Slack-Signature header
TwilioSMS, callsAuth token in request params

What to Test

1. Payload Validation

Verify your receiver correctly parses the webhook payload:

// Test: Valid Stripe webhook payload
const payload = {
  id: 'evt_123',
  type: 'payment_intent.succeeded',
  data: {
    object: {
      id: 'pi_123',
      amount: 2000,
      currency: 'usd',
      status: 'succeeded',
    }
  }
};

const response = await fetch('http://localhost:3000/webhooks/stripe', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
});

expect(response.status).toBe(200);
// Verify: payment recorded in database

2. Signature Verification

Test that your receiver rejects unsigned or tampered payloads:

// Test: Missing signature — should be rejected
const response = await fetch('http://localhost:3000/webhooks/stripe', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
  // No Stripe-Signature header
});
expect(response.status).toBe(401);

// Test: Invalid signature — should be rejected
const response2 = await fetch('http://localhost:3000/webhooks/stripe', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Stripe-Signature': 't=1234,v1=invalidsignature',
  },
  body: JSON.stringify(payload),
});
expect(response2.status).toBe(401);

3. Idempotency

The same webhook may be delivered multiple times. Your receiver must handle duplicates:

// Send the same event twice
await fetch(webhookUrl, { method: 'POST', body: JSON.stringify(payload) });
await fetch(webhookUrl, { method: 'POST', body: JSON.stringify(payload) });

// Verify: only one payment record created, not two
const payments = await db.query('SELECT * FROM payments WHERE stripe_event_id = $1', ['evt_123']);
expect(payments.length).toBe(1);

4. Error Handling and Retry

Test what happens when your receiver fails:

  • Return 500: Provider should retry.
  • Return 200 but processing fails internally: Your system should queue for reprocessing.
  • Receiver is down: Provider retries with exponential backoff.

5. Event Ordering

Webhooks may arrive out of order. A payment.refunded event might arrive before payment.succeeded:

// Send events out of order
await sendWebhook({ type: 'payment.refunded', data: { ... } });
await sendWebhook({ type: 'payment.succeeded', data: { ... } });

// Verify: system handles this gracefully

Testing Tools

webhook.site

A free service that gives you a unique URL to receive and inspect webhooks:

  1. Go to https://webhook.site — you get a unique URL.
  2. Configure your provider to send webhooks to that URL.
  3. Trigger events and inspect the received payloads in your browser.

ngrok

Exposes your local development server to the internet:

# Start your local webhook receiver
node server.js  # Runs on localhost:3000

# In another terminal, expose it
ngrok http 3000
# Returns: https://abc123.ngrok.io

# Configure provider to send to: https://abc123.ngrok.io/webhooks/stripe

Local Test Server

Build a simple receiver for testing:

const express = require('express');
const app = express();
const receivedWebhooks = [];

app.post('/webhooks/:provider', express.json(), (req, res) => {
  receivedWebhooks.push({
    provider: req.params.provider,
    headers: req.headers,
    body: req.body,
    timestamp: new Date(),
  });
  res.status(200).json({ received: true });
});

app.get('/webhooks/history', (req, res) => {
  res.json(receivedWebhooks);
});

app.listen(3000);

Exercise: Webhook Testing Lab

Setup

Create a webhook receiver and test it thoroughly.

Task 1: Build a Webhook Receiver

Create an Express.js webhook receiver that:

  1. Accepts POST requests at /webhooks/payments.
  2. Validates HMAC-SHA256 signature using a shared secret.
  3. Parses the payload and stores the event.
  4. Returns 200 for valid webhooks, 401 for invalid signatures, 400 for malformed payloads.
  5. Deduplicates events by event ID.

Task 2: Signature Testing

Write tests that verify:

  1. Valid signature is accepted (generate proper HMAC with the shared secret).
  2. Missing signature header is rejected with 401.
  3. Invalid signature is rejected with 401.
  4. Tampered payload (valid signature but modified body) is rejected.

Task 3: Idempotency Testing

  1. Send the same event 5 times.
  2. Verify only 1 record is created in the database.
  3. Verify all 5 requests return 200 (not just the first).

Task 4: Error Scenario Testing

  1. Send a malformed JSON payload — verify 400 response.
  2. Send a valid payload but simulate database failure — verify the receiver queues for retry.
  3. Send events out of order — verify the system handles it.

Task 5: Load Testing Webhooks

Some providers send many webhooks in bursts (e.g., batch processing):

  1. Send 100 webhooks in rapid succession (concurrent).
  2. Verify all 100 are received and processed.
  3. Verify no data corruption from concurrent processing.

Deliverables

  1. Webhook receiver code with signature verification.
  2. Test suite covering all scenarios.
  3. Report documenting test results and any issues found.