> ## Documentation Index
> Fetch the complete documentation index at: https://docs.headlesscommerce.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time event notifications and process them reliably

# 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:

```bash theme={null}
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 `*`:

```typescript theme={null}
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

| Event                    | Description                               | Payload                             |
| ------------------------ | ----------------------------------------- | ----------------------------------- |
| `product.created`        | A product was created                     | Full product object                 |
| `product.updated`        | A product was updated                     | Updated product object              |
| `product.deleted`        | A product was deleted                     | Product ID and metadata             |
| `order.created`          | An order was created                      | Full order object                   |
| `order.confirmed`        | An order was confirmed (payment received) | Order with payment details          |
| `order.completed`        | An order was fully completed              | Order with fulfillment IDs          |
| `order.cancelled`        | An order was cancelled                    | Order with cancellation reason      |
| `payment.completed`      | A payment was successfully processed      | Order with payment details          |
| `payment.failed`         | A payment attempt failed                  | Order with error details            |
| `payment.refunded`       | A payment was refunded                    | Refund with amount and line items   |
| `fulfillment.created`    | A fulfillment was created                 | Fulfillment with items              |
| `fulfillment.shipped`    | A fulfillment was shipped                 | Fulfillment with tracking info      |
| `fulfillment.delivered`  | A fulfillment was delivered               | Fulfillment with delivery timestamp |
| `return.requested`       | A customer requested a return             | Return with items and reason        |
| `return.completed`       | A return was fully processed              | Return with resolution details      |
| `inventory.low`          | Inventory fell below safety stock         | Variant and stock levels            |
| `inventory.out_of_stock` | Available inventory reached zero          | Variant ID                          |
| `customer.created`       | A customer was created                    | Customer object (no password)       |

## Payload Format

```json theme={null}
{
  "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

```typescript theme={null}
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:

```typescript theme={null}
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'),
  );
}
```

<Warning>
  Always use `crypto.timingSafeEqual` instead of `===` for signature comparison.
  String equality is vulnerable to timing attacks.
</Warning>

## 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).

```bash theme={null}
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"}'
```

| Endpoint                               | Why                       |
| -------------------------------------- | ------------------------- |
| `POST /storefront/carts/{id}/checkout` | Prevent duplicate orders  |
| `POST /admin/orders/{id}/payments`     | Prevent duplicate charges |
| `POST /admin/orders/{id}/refunds`      | Prevent 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.

```typescript theme={null}
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:

```sql theme={null}
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';
```

<Tip>
  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.
</Tip>

## Retry Policy

Failed deliveries are retried with exponential backoff:

| Attempt   | Delay After Failure |
| --------- | ------------------- |
| 1st retry | 1 minute            |
| 2nd retry | 5 minutes           |
| 3rd retry | 30 minutes          |
| 4th retry | 2 hours             |
| 5th retry | 24 hours            |

After 5 consecutive failures, the webhook endpoint is marked as **inactive** and a notification is sent to the dashboard.

### Fast response best practices

```typescript theme={null}
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);
  });
});
```

<Warning>
  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.
</Warning>

## 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

```typescript theme={null}
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

```typescript theme={null}
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

```typescript theme={null}
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:

```bash theme={null}
curl -X POST https://api.headlesscommerce.io/v1/admin/webhooks/{id}/test \
  -H "Authorization: Bearer sk_test_your_key"
```

Or with the SDK:

```typescript theme={null}
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:

```bash theme={null}
# 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"]
  }'
```

<Tip>
  Remember to update or delete your development webhooks when you are done testing. Stale endpoints will accumulate failed deliveries and may be auto-deactivated.
</Tip>

## Next Steps

<CardGroup cols={2}>
  <Card title="Stripe Integration" icon="stripe" href="/guides/stripe-integration">
    Set up Stripe payments with webhook-driven confirmation.
  </Card>

  <Card title="TossPayments" icon="credit-card" href="/guides/tosspayments-integration">
    Set up TossPayments with webhook-driven confirmation.
  </Card>

  <Card title="Recipes" icon="book" href="/guides/recipes">
    Practical code recipes covering common commerce flows.
  </Card>

  <Card title="Error Handling" icon="triangle-exclamation" href="/errors">
    API error codes and recommended handling strategies.
  </Card>
</CardGroup>
