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)
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:
Header Description 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 type Fires 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 type Fires 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