Skip to main content

Live Example

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


1. Register a webhook endpoint

Go to the Henry Dashboard, 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.
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:
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:
HeaderDescription
X-Henry-SignatureHMAC-SHA256 hex signature of {timestamp}.{body}
X-Henry-TimestampUnix timestamp in milliseconds
Content-Typeapplication/json
Always verify the signature before processing the payload.

Verification logic

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);
}
Always read the raw request body before parsing it as JSON. Parsing first can change the string representation and break signature verification.

Full server example (Node.js / Express)

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)

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:
type HenryWebhookPayload = {
	event: string; // e.g. "order.purchase"
	timestamp: string; // ISO 8601
	data: OrderEventData | PointsEventData | TierEventData;
};

Example payload

{
	"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": { "amount": "150.00", "currency": "USD" },
				"commissionFee": { "amount": "7.50", "currency": "USD" },
				"total": { "amount": "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 typeFires when
orderAny order event
order.purchaseAny purchase event (full or partial, any outcome)
order.purchase.pendingPurchase created, awaiting payment confirmation
order.purchase.processingPayment confirmed, placing with merchants
order.purchase.completePurchase completed (full or partial)
order.purchase.cancelledPurchase cancelled (full or partial)
order.purchase.fullA full purchase resolves to any outcome
order.purchase.full.pendingFull purchase pending payment confirmation
order.purchase.full.processingFull purchase confirmed, placing with merchants
order.purchase.full.completeAll items placed successfully
order.purchase.full.cancelledFull purchase cancelled (payment or merchant failure)
order.purchase.partialA partial purchase resolves to any outcome
order.purchase.partial.pendingPartial purchase pending
order.purchase.partial.processingPartial purchase processing
order.purchase.partial.completePartial purchase completed
order.purchase.partial.cancelledPartial purchase cancelled
order.itemAny item-level event
order.item.pendingIndividual item pending
order.item.processingIndividual item processing
order.item.completeIndividual item placed successfully
order.item.failedIndividual item failed

Points & tier events

Event typeFires when
pointsAny points event
points.givePoints awarded to a buyer
points.removePoints removed from a buyer
tierAny tier event
tier.giveA tier assigned to a buyer
tier.removeA tier removed from a buyer
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.

Conditionals

Each event in settings.events accepts an optional conditional that filters when the trigger fires. Two types are supported:
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

Polling

Prefer polling for product search and details jobs

Universal Cart

Configure webhook events when creating a cart