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

# Webhooks

> Receive real-time order and fulfillment events pushed directly to your server - no polling required

## Overview

Instead of polling for order status, you can configure Henry to push events directly to your server via webhooks. When an order transitions state - placed, completed, cancelled - Henry fires an HTTP POST to your endpoint with a signed payload.

Webhooks are configured per-cart via the `settings.events` array on `cart.create`.

***

## How it works

```mermaid theme={null}
sequenceDiagram
    participant App as Your App
    participant Henry as Henry

    App->>Henry: cart.create({ items, settings: { events } })
    Henry-->>App: { cartId, checkoutUrl }

    Note over App,Henry: User completes checkout...

    Henry->>App: POST /webhook<br/>{ event, data, timestamp }<br/>X-Henry-Signature, X-Henry-Timestamp
    App->>App: Verify signature
    App-->>Henry: 200 OK
```

***

## 1. Register a webhook endpoint

Go to the [Henry Dashboard](https://app.henrylabs.ai), open your app settings, and add a webhook endpoint URL. Henry will generate a **webhook UUID** and a **webhook secret** for that endpoint.

* The **UUID** is used in cart settings to reference the webhook
* The **secret** is used to verify incoming requests (keep it private, like an API key)

***

## 2. Configure a cart with webhook events

Pass the webhook UUID in `settings.events` when creating a cart. Henry fires that webhook when the specified event occurs.

```typescript theme={null}
const cart = await henry.cart.create({
	items: [
		{
			link: 'https://www.petbarn.com.au/p/love-em-air-dried-beef-liver-dog-treat/139654',
			quantity: 1,
		},
	],
	settings: {
		events: [
			{
				type: 'order.purchase', // fire for any update to the purchase
				data: [
					{
						type: 'send_webhook',
						webhookUUID: 'c5dbd16b-60f1-42c7-8d36-a55c46e7a8c6',
					},
				],
			},
		],
	},
});

const { checkoutUrl } = cart.data;
```

To fire on specific outcomes, use a more granular event type:

```typescript theme={null}
settings: {
  events: [
    {
      type: 'order.purchase.complete',   // only on a completed purchase
      data: [{ type: 'send_webhook', webhookUUID: '...' }],
    },
    {
      type: 'order.purchase.cancelled',  // only on a cancelled purchase
      data: [{ type: 'send_webhook', webhookUUID: '...' }],
    },
  ],
}
```

***

## 3. Receive and verify the webhook

Henry sends a `POST` request with three important headers:

| Header              | Description                                       |
| ------------------- | ------------------------------------------------- |
| `X-Henry-Signature` | HMAC-SHA256 hex signature of `{timestamp}.{body}` |
| `X-Henry-Timestamp` | Unix timestamp in milliseconds                    |
| `Content-Type`      | `application/json`                                |

**Always verify the signature** before processing the payload.

### Verification logic

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'crypto';

const WEBHOOK_SECRET = process.env.HENRY_WEBHOOK_SECRET!;
const REPLAY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes

function verifyHenryWebhook(rawBody: string, signature: string | null, timestamp: string | null): boolean {
	if (!signature || !timestamp) return false;

	const ts = Number(timestamp);

	// Reject stale requests (replay attack prevention)
	if (Math.abs(Date.now() - ts) > REPLAY_WINDOW_MS) return false;

	// Reconstruct the signed payload
	const signedPayload = `${ts}.${rawBody}`;

	// Constant-time comparison to prevent timing attacks
	const expected = createHmac('sha256', WEBHOOK_SECRET).update(signedPayload).digest();
	const actual = Buffer.from(signature, 'hex');
	return expected.byteLength === actual.byteLength && timingSafeEqual(expected, actual);
}
```

<Warning>Always read the raw request body **before** parsing it as JSON. Parsing first can change the string representation and break signature verification.</Warning>

### Full server example (Node.js / Express)

```typescript theme={null}
import express from 'express';
import { createHmac } from 'crypto';

const app = express();
const WEBHOOK_SECRET = process.env.HENRY_WEBHOOK_SECRET!;

// Use raw body parser for the webhook route
app.post('/webhook/henry', express.raw({ type: 'application/json' }), (req, res) => {
	const rawBody = req.body.toString('utf8');
	const signature = req.headers['x-henry-signature'] as string;
	const timestamp = req.headers['x-henry-timestamp'] as string;

	if (!verifyHenryWebhook(rawBody, signature, timestamp)) {
		return res.status(401).send('Unauthorized');
	}

	let event: HenryWebhookPayload;
	try {
		event = JSON.parse(rawBody);
	} catch {
		return res.status(400).send('Invalid JSON');
	}

	// Always respond 200 quickly - process async
	res.json({ received: true });
	handleEvent(event).catch(console.error);
});

async function handleEvent(event: HenryWebhookPayload) {
	console.log(`[${event.timestamp}] ${event.event}`, event.data);

	switch (event.event) {
		case 'order.purchase.full.complete':
			await fulfillOrder(event.data);
			break;
		case 'order.purchase.full.cancelled':
			await notifyUserOfCancellation(event.data);
			break;
	}
}
```

### Full server example (Bun)

```typescript theme={null}
import { timingSafeEqual } from 'crypto';

const WEBHOOK_SECRET = process.env.HENRY_WEBHOOK_SECRET ?? '';
const REPLAY_WINDOW_MS = 5 * 60 * 1000;

function verifySignature(body: string, signature: string | null, timestamp: string | null): boolean {
	if (!WEBHOOK_SECRET) return true; // skip in dev if no secret set
	if (!signature || !timestamp) return false;

	const ts = Number(timestamp);
	if (Math.abs(Date.now() - ts) > REPLAY_WINDOW_MS) return false;

	const signedPayload = `${ts}.${body}`;
	const hasher = new Bun.CryptoHasher('sha256', WEBHOOK_SECRET);
	hasher.update(signedPayload);
	const expected = Buffer.from(hasher.digest('hex'), 'hex');
	const actual = Buffer.from(signature, 'hex');
	return expected.byteLength === actual.byteLength && timingSafeEqual(expected, actual);
}

Bun.serve({
	port: 6789,
	async fetch(req) {
		const url = new URL(req.url);

		if (url.pathname === '/webhook' && req.method === 'POST') {
			const body = await req.text();
			const signature = req.headers.get('x-henry-signature');
			const timestamp = req.headers.get('x-henry-timestamp');

			if (!verifySignature(body, signature, timestamp)) {
				return new Response('Unauthorized', { status: 401 });
			}

			let event: HenryWebhookPayload;
			try {
				event = JSON.parse(body);
			} catch {
				return new Response('Invalid JSON', { status: 400 });
			}

			// Respond immediately, process async
			handleEvent(event).catch(console.error);
			return Response.json({ received: true });
		}

		return new Response('Not Found', { status: 404 });
	},
});
```

***

## Webhook payload structure

Henry sends a consistent envelope for all events:

```typescript theme={null}
type HenryWebhookPayload = {
	event: string; // e.g. "order.purchase"
	timestamp: string; // ISO 8601
	data: OrderEventData | PointsEventData | TierEventData;
};
```

### Example payload

```json theme={null}
{
	"event": "order.purchase.full.complete",
	"timestamp": "2025-03-17T14:32:00.000Z",
	"data": {
		"status": "complete",
		"orderId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
		"cartId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
		"items": [
			{
				"status": "complete",
				"quantity": 1,
				"productLink": "https://www.nike.com/t/air-max-270-shoes/AH8050-002",
				"productRawUrl": "https://www.nike.com/t/air-max-270-shoes/AH8050-002",
				"merchantName": "Nike",
				"variant": { "size": "10", "color": "Black" },
				"metadata": { "source": "homepage" }
			}
		],
		"details": {
			"name": { "firstName": "Jane", "lastName": "Doe" },
			"email": "jane@example.com"
		},
		"results": {
			"costs": {
				"subtotal": { "value": 150.00, "currency": "USD" },
				"serviceFee": { "value": 7.50, "currency": "USD" },
				"total": { "value": 157.50, "currency": "USD" }
			}
		}
	}
}
```

***

## Event types reference

Events follow a dot-notation hierarchy. Subscribing to a parent event fires for all child events.

### Order events

`order.purchase.full` vs `order.purchase`: a **full** purchase means every item in the cart was attempted together. A non-full purchase (e.g. `order.purchase.complete`) covers any purchase outcome regardless of whether it was full or partial.

| Event type                             | Fires when                                            |
| -------------------------------------- | ----------------------------------------------------- |
| `order`                                | Any order event                                       |
| `order.purchase`                       | Any purchase event (full or partial, any outcome)     |
| `order.purchase.pending`               | Purchase created, awaiting payment confirmation       |
| `order.purchase.processing`            | Payment confirmed, placing with merchants             |
| `order.purchase.complete`              | Purchase completed (full or partial)                  |
| `order.purchase.cancelled`             | Purchase cancelled (full or partial)                  |
| `order.purchase.full`                  | A full purchase resolves to any outcome               |
| `order.purchase.full.pending`          | Full purchase pending payment confirmation            |
| `order.purchase.full.processing`       | Full purchase confirmed, placing with merchants       |
| `order.purchase.full.complete`         | All items placed successfully                         |
| `order.purchase.full.cancelled`        | Full purchase cancelled (payment or merchant failure) |
| `order.purchase.partial`               | A partial purchase resolves to any outcome            |
| `order.purchase.partial.pending`       | Partial purchase pending                              |
| `order.purchase.partial.processing`    | Partial purchase processing                           |
| `order.purchase.partial.complete`      | Partial purchase completed                            |
| `order.purchase.partial.cancelled`     | Partial purchase cancelled                            |
| `order.item`                           | Any item-level event                                  |
| `order.item.pending`                   | Individual item pending                               |
| `order.item.processing`                | Individual item processing                            |
| `order.item.complete`                  | Individual item placed successfully                   |
| `order.item.failed`                    | Individual item failed                                |
| `order.update`                         | Any order update event                                |
| `order.update.3ds-required`            | 3DS authentication required                           |
| `order.update.adding-to-cart`          | Adding item to merchant cart                          |
| `order.update.filling-address-details` | Filling in address details                            |
| `order.update.filling-card-details`    | Filling in card details                               |
| `order.update.submitting-order`        | Submitting the order                                  |
| `order.update.changing-quantity`       | Changing item quantity                                |
| `order.update.applying-coupon`         | Applying a coupon code                                |
| `order.update.selecting-shipping`      | Selecting shipping option                             |
| `order.update.selecting-options`       | Selecting product options                             |

### Points & tier events

| Event type      | Fires when                  |
| --------------- | --------------------------- |
| `points`        | Any points event            |
| `points.give`   | Points awarded to a buyer   |
| `points.remove` | Points removed from a buyer |
| `tier`          | Any tier event              |
| `tier.give`     | A tier assigned to a buyer  |
| `tier.remove`   | A tier removed from a buyer |

<Tip>For most use cases, subscribe to `order.purchase.full.complete` and `order.purchase.full.cancelled` - this covers both success and failure for a standard checkout.</Tip>

***

## Conditionals

Each event in `settings.events` accepts an optional `conditional` that filters when the trigger fires. Two types are supported:

```typescript theme={null}
settings: {
  events: [
    {
      type: 'order.purchase.full.complete',
      conditional: {
        type: 'tier',
        operator: 'equals',   // 'equals' | 'not_equals'
        value: 'your-tier-uuid',
      },
      data: [{ type: 'send_webhook', webhookUUID: '...' }],
    },
    {
      type: 'points.give',
      conditional: {
        type: 'points',
        operator: 'greater_than_or_equal',  // 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'greater_than_or_equal' | 'less_than_or_equal'
        value: 100,
      },
      data: [{ type: 'send_webhook', webhookUUID: '...' }],
    },
  ],
}
```

***

## Next steps

<CardGroup cols={2}>
  <Card title="Polling" icon="rotate" href="/v1/sdk/server/guides/polling">
    Prefer polling for product search and details jobs
  </Card>

  <Card title="Universal Cart" icon="cart-shopping" href="/v1/sdk/server/guides/universal-cart">
    Configure webhook events when creating a cart
  </Card>
</CardGroup>
