Skip to main content

Webhooks

Webhooks notify your application when events occur in your store — such as order confirmations, payment completions, or low inventory alerts.

Setting Up

Create a webhook via the Admin API:
curl -X POST https://api.headlesscommerce.io/v1/admin/webhooks \
  -H "Authorization: Bearer sk_test_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks",
    "events": ["order.confirmed", "payment.completed"]
  }'
The response includes a secret for verifying webhook signatures. To subscribe to all events, use the wildcard *:
const admin = createAdminClient({ apiKey: 'sk_live_xxx' });

const webhook = await admin.webhooks.create({
  url: 'https://my-app.com/webhooks/hc',
  events: ['*'],
});

// The response includes the webhook secret for signature verification
console.log(webhook.secret); // "whsec_..."

Event Types

EventDescriptionPayload
product.createdA product was createdFull product object
product.updatedA product was updatedUpdated product object
product.deletedA product was deletedProduct ID and metadata
order.createdAn order was createdFull order object
order.confirmedAn order was confirmed (payment received)Order with payment details
order.completedAn order was fully completedOrder with fulfillment IDs
order.cancelledAn order was cancelledOrder with cancellation reason
payment.completedA payment was successfully processedOrder with payment details
payment.failedA payment attempt failedOrder with error details
payment.refundedA payment was refundedRefund with amount and line items
fulfillment.createdA fulfillment was createdFulfillment with items
fulfillment.shippedA fulfillment was shippedFulfillment with tracking info
fulfillment.deliveredA fulfillment was deliveredFulfillment with delivery timestamp
return.requestedA customer requested a returnReturn with items and reason
return.completedA return was fully processedReturn with resolution details
inventory.lowInventory fell below safety stockVariant and stock levels
inventory.out_of_stockAvailable inventory reached zeroVariant ID
customer.createdA customer was createdCustomer object (no password)

Payload Format

{
  "id": "evt_abc123",
  "type": "order.confirmed",
  "created_at": "2025-01-15T10:30:00Z",
  "data": {
    "id": "order_001",
    "number": 1001,
    "status": "confirmed",
    "total": { "amount": 78000, "currency": "KRW" }
  },
  "store_id": "store_001"
}

Signature Verification

Every webhook delivery includes an X-Webhook-Signature header (HMAC-SHA256). Always verify this before processing.

Using the SDK helper

import { verifyWebhookSignature } from '@headless-commerce/sdk';

app.post('/webhooks/hc', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  const secret = process.env.WEBHOOK_SECRET!;

  const isValid = verifyWebhookSignature(req.body, signature, secret);

  if (!isValid) {
    console.error('Webhook signature verification failed');
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Safe to process the event
  const event = JSON.parse(req.body.toString());
  // ...
});

Manual verification

If you are not using the SDK, compute the signature yourself:
import crypto from 'node:crypto';

function verifyManually(payload: Buffer, signature: string, secret: string): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'utf8'),
    Buffer.from(expected, 'utf8'),
  );
}
Always use crypto.timingSafeEqual instead of === for signature comparison. String equality is vulnerable to timing attacks.

Idempotency

HTTP Idempotency Keys

Use idempotency keys to safely retry POST requests without creating duplicate resources. Include an Idempotency-Key header with a unique value (UUID recommended).
curl -X POST https://api.headlesscommerce.io/v1/storefront/carts/{id}/checkout \
  -H "Authorization: Bearer pk_test_your_key" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -H "Content-Type: application/json" \
  -d '{"email": "customer@example.com", "payment_provider": "stripe"}'
EndpointWhy
POST /storefront/carts/{id}/checkoutPrevent duplicate orders
POST /admin/orders/{id}/paymentsPrevent duplicate charges
POST /admin/orders/{id}/refundsPrevent double refunds
Key rules:
  • Keys must be unique per request — use UUIDs
  • Keys are scoped to the store (API key)
  • Cached responses expire after 24 hours
  • Only successful responses (2xx) are cached
  • The Idempotency-Key header is optional. If omitted, the request is processed normally

Idempotent Webhook Processing

Webhooks may be delivered more than once (due to retries, network issues, or infrastructure failover). Your handler must be idempotent.
import { db } from './database';

async function handleWebhook(event: WebhookEvent): Promise<boolean> {
  // 1. Check if this event was already processed
  const existing = await db.webhookEvent.findUnique({
    where: { event_id: event.id },
  });

  if (existing) {
    console.log(`Event ${event.id} already processed, skipping`);
    return true; // Already handled
  }

  // 2. Process the event inside a transaction
  await db.$transaction(async (tx) => {
    // Record the event FIRST to claim it
    await tx.webhookEvent.create({
      data: {
        event_id: event.id,
        type: event.type,
        processed_at: new Date(),
      },
    });

    // Then perform business logic
    switch (event.type) {
      case 'order.paid':
        await tx.order.update({
          where: { id: event.data.id },
          data: { payment_status: 'paid' },
        });
        break;
      // ... other event types
    }
  });

  return true;
}
Database schema for deduplication:
CREATE TABLE webhook_events (
  event_id   VARCHAR(64) PRIMARY KEY,  -- e.g., "evt_abc123"
  type       VARCHAR(128) NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  payload    JSONB
);

-- Add a TTL index to auto-clean old records (PostgreSQL + pg_cron)
-- DELETE FROM webhook_events WHERE processed_at < NOW() - INTERVAL '30 days';
Use a database transaction that inserts the event ID and performs the business logic atomically. This ensures that if processing fails, the event is not marked as handled and will be retried.

Retry Policy

Failed deliveries are retried with exponential backoff:
AttemptDelay After Failure
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry24 hours
After 5 consecutive failures, the webhook endpoint is marked as inactive and a notification is sent to the dashboard.

Fast response best practices

app.post('/webhooks/hc', express.raw({ type: 'application/json' }), async (req, res) => {
  // 1. Verify signature
  if (!verifyWebhookSignature(req.body, req.headers['x-webhook-signature'], secret)) {
    return res.status(401).send('Invalid signature');
  }

  // 2. Return 200 immediately -- process asynchronously
  res.status(200).send('OK');

  // 3. Process in the background (after response is sent)
  const event = JSON.parse(req.body.toString());
  processEventAsync(event).catch((err) => {
    console.error(`Failed to process event ${event.id}:`, err);
  });
});
Return a 2xx response as quickly as possible. If your handler takes more than 30 seconds, the delivery will be considered failed and retried. Move heavy processing to a background queue.

Event Ordering

Events may arrive out of order. For example, order.paid might arrive before order.created due to network conditions.

Strategy: Use timestamps, not arrival order

async function handleOrderUpdate(event: WebhookEvent) {
  const order = await db.order.findUnique({ where: { id: event.data.id } });

  if (!order) {
    // Order not in our system yet -- store the event for later processing
    await db.pendingEvent.create({ data: { event_id: event.id, payload: event } });
    return;
  }

  // Only apply if the event is newer than our last update
  const eventTime = new Date(event.created_at);
  if (order.last_webhook_at && eventTime <= order.last_webhook_at) {
    console.log(`Skipping stale event ${event.id}`);
    return;
  }

  await db.order.update({
    where: { id: event.data.id },
    data: {
      status: event.data.status,
      last_webhook_at: eventTime,
    },
  });
}
If you receive an event for a resource that does not exist in your system yet:
  1. Store the event in a pending queue
  2. When the prerequisite event arrives (e.g., order.created), process it first
  3. Then replay any pending events for that resource

Queue-Based Processing

For production workloads, accept webhooks into a queue and process them asynchronously.

With Inngest

import { Inngest } from 'inngest';
import { verifyWebhookSignature } from '@headless-commerce/sdk';

const inngest = new Inngest({ id: 'my-store' });

// Define the handler function
const processWebhook = inngest.createFunction(
  { id: 'process-hc-webhook', retries: 3 },
  { event: 'hc/webhook.received' },
  async ({ event }) => {
    const { type, data } = event.data;

    switch (type) {
      case 'order.created':
        await sendOrderConfirmation(data);
        await syncToERP(data);
        break;
      case 'fulfillment.shipped':
        await sendShippingNotification(data);
        break;
      case 'return.requested':
        await notifyWarehouse(data);
        break;
    }
  },
);

// Webhook endpoint: verify, enqueue, respond immediately
app.post('/webhooks/hc', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  if (!verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());

  // Send to Inngest for async processing
  await inngest.send({
    name: 'hc/webhook.received',
    data: event,
  });

  res.status(200).send('OK');
});

With BullMQ

import { Queue, Worker } from 'bullmq';
import { verifyWebhookSignature } from '@headless-commerce/sdk';

const webhookQueue = new Queue('hc-webhooks', {
  connection: { host: 'localhost', port: 6379 },
});

// Webhook endpoint: verify and enqueue
app.post('/webhooks/hc', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  if (!verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());
  await webhookQueue.add(event.type, event, {
    jobId: event.id, // Prevents duplicate processing
  });

  res.status(200).send('OK');
});

// Worker: process events from the queue
const worker = new Worker('hc-webhooks', async (job) => {
  const event = job.data;
  console.log(`Processing ${event.type}: ${event.id}`);

  switch (event.type) {
    case 'order.paid':
      await fulfillOrder(event.data);
      break;
    case 'refund.created':
      await updateAccountingSystem(event.data);
      break;
  }
}, {
  connection: { host: 'localhost', port: 6379 },
  concurrency: 5,
});

Testing

Send a test event

Use the Admin API to send a test event to your webhook endpoint:
curl -X POST https://api.headlesscommerce.io/v1/admin/webhooks/{id}/test \
  -H "Authorization: Bearer sk_test_your_key"
Or with the SDK:
const admin = createAdminClient({ apiKey: 'sk_test_xxx' });
await admin.webhooks.test('wh_abc123');
The test endpoint sends a synthetic event with "test": true in the payload so you can distinguish test events from real ones.

Local development with ngrok

To receive webhooks on your local machine during development:
# 1. Start ngrok to expose your local server
ngrok http 3000

# 2. Copy the HTTPS URL (e.g., https://abc123.ngrok.io)

# 3. Register the webhook with your ngrok URL
curl -X POST https://api.headlesscommerce.io/v1/admin/webhooks \
  -H "Authorization: Bearer sk_test_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.io/webhooks/hc",
    "events": ["order.created", "order.paid"]
  }'
Remember to update or delete your development webhooks when you are done testing. Stale endpoints will accumulate failed deliveries and may be auto-deactivated.

Next Steps

Stripe Integration

Set up Stripe payments with webhook-driven confirmation.

TossPayments

Set up TossPayments with webhook-driven confirmation.

Recipes

Practical code recipes covering common commerce flows.

Error Handling

API error codes and recommended handling strategies.