Checkout Hook
The Checkout Hook is a mechanism that allows you to inject custom logic into the checkout process before the order is finalized and the Stripe session is created.
When a customer starts a checkout, Webround collects all the necessary data—products, prices, taxes, shipping, customer data—signs it, and sends it to your endpoint. You can modify line items and order items, add custom metadata, and respond. Webround validates your response and proceeds with your data.
The Vision
The Checkout Hook is designed for use cases where pricing logic or order composition depends on data only you know: B2B contracts, custom price lists per customer, discounts based on company attributes, or integrations with external ERP or CRM systems.
Webround does not expose endpoints to send data to in advance. Instead, Webround contacts you at the right moment in the checkout flow. You respond with the modified data, and Webround uses it to create the order and the payment session.
This means you don't have to install anything on Webround. You only need to expose a publicly reachable HTTPS endpoint. You can use any technology and host it wherever you prefer.
Configuration
The Checkout Hook is configured in the dedicated section of the Webround Commerce admin panel.
| Parameter | Description |
|---|---|
| URL | The HTTPS endpoint Webround will call during checkout |
| Secret | Key generated by Webround to sign requests—shown only once |
| Timeout | Maximum wait time in ms (default 5000) |
| On Error | passthrough — proceeds without changes on error; abort — blocks checkout |
The secret is generated by Webround at creation time and shown only once. Store it securely. Webround never shows it again. Do not share it with anyone. If you need support, contact us.
Flow
Customer starts checkout
↓
Webround validates cart, calculates prices, taxes, shipping, promotions
↓
Webround signs the payload and calls your endpoint
↓
Your endpoint validates the signature, processes, responds with signature
↓
Webround validates your response
↓
Webround creates order and payment session with your data
If your endpoint does not respond within the timeout or returns an error:
- With
onError: passthrough→ Webround proceeds with original data, no additional logic is applied - With
onError: abort→ the checkout is blocked with a 500 error
Request Format
Webround sends a POST request to your endpoint with Content-Type: application/json.
The body contains:
{
"version": 1,
"storeId": "store-uuid",
"timestamp": 1745000000000,
"signature": "hmac-sha256...",
"store": {
"id": "store-uuid",
"plan": "start",
"countryCode": "IT",
"langCode": "it",
"defaultLangCode": "it"
},
"customer": {
"id": "customer-uuid",
"email": "[email protected]",
"isAuthenticated": true,
"emailVerified": true,
"isVatExempt": false,
"vatCode": "IT04917010615",
"displayName": "Mario Rossi",
"phone": null,
"newsletter": true,
"lifetimeValue": { "EUR": { "totalSpend": 1500.00, "totalRefund": 0 } },
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
},
"addresses": {
"shipping": { "fullName": "Mario Rossi", "countryCode": "IT", "addressLine1": "Via Roma 1", "city": "Rome", "postalCode": "00100", "province": "RM"},
"billing": { "fullName": "Mario Rossi", "countryCode": "IT", "addressLine1": "Via Roma 1", "city": "Rome", "postalCode": "00100", "province": "RM" }
},
"requestsInvoice": false,
"deliveryMethods": { "variant-uuid": "shipping" },
"selectedShippingMethodId": "shipping-method-uuid",
"promotionCodes": [],
"promotionSnapshot": [],
"items": [
{
"variantId": "uuid",
"productId": "uuid",
"priceId": "uuid",
"skuCode": "SKU001",
"quantity": 2,
"currencyCode": "EUR",
"deliveryMethod": "shipping",
"unitNet": 100.00,
"unitTax": 22.00,
"unitGross": 122.00,
"totalNet": 200.00,
"totalTax": 44.00,
"totalGross": 244.00,
"appliedTaxRate": 22,
"taxBehavior": "useWrTax",
"coverUrl": "https://...",
"optionsSnapshot": [],
"stripeProductId": null,
"stripePriceId": null,
"stripeTaxCodeId": null,
"totalStock": 50,
"cadence": "once"
}
],
"shippingOptions": [...],
"lineItems": [...]
}
Security — Validating Inbound Signature
Every Webround request includes an HMAC-SHA256 signature in the body as the signature field.
The signature is calculated on the entire body excluding the signature field itself:
bodyHash = SHA256(JSON.stringify(body without signature))
sigPayload = "{version}.{storeId}.{timestamp}.{bodyHash}"
signature = HMAC-SHA256(secret, sigPayload)
You must always validate the signature before processing the request.
You must also validate the timestamp: if the difference between the timestamp in the request and the current time exceeds 30 seconds, the request must be rejected. This neutralizes replay attacks—an attacker intercepting a legitimate request cannot re-send it after the window has expired.
function verifyIncoming(body, secret) {
const { signature, ...rest } = body;
const bodyHash = sha256(JSON.stringify(rest));
const sigPayload = `${body.version}.${body.storeId}.${body.timestamp}.${bodyHash}`;
const expected = hmacSha256(secret, sigPayload);
// use timing-safe comparison to prevent timing attacks
return timingSafeEqual(expected, signature);
}
const isValid = verifyIncoming(body, secret);
if (!isValid) return Response(401);
const age = Math.abs(Date.now() - body.timestamp);
if (age > 30_000) return Response(401);
Response Format
Webround expects a response with Content-Type: application/json and status 200.
The body must be exactly:
{
"version": 1,
"storeId": "store-uuid",
"timestamp": 1745000000000,
"signature": "hmac-sha256...",
"orderItems": [...],
"lineItems": [...],
"additionalData": {}
}
| Field | Type | Description |
|---|---|---|
version | number | Must be 1 |
storeId | string | The same storeId received |
timestamp | number | Unix timestamp in milliseconds used for signing |
signature | string | HMAC-SHA256 signature of your response |
orderItems | array | At least 1 item — see schema below |
lineItems | array | At least 1 item — Stripe-compatible line items |
additionalData | object | Optional — custom metadata saved to the order |
orderItem Schema
{
"variantId": "uuid",
"productId": "uuid",
"priceId": "uuid",
"skuCode": "SKU001",
"quantity": 2,
"unitNet": 90.00,
"unitTax": 19.80,
"unitGross": 109.80,
"totalNet": 180.00,
"totalTax": 39.60,
"totalGross": 219.60,
"appliedTaxRate": 22,
"taxBehavior": "useWrTax",
"deliveryMethod": "shipping",
"coverUrl": null,
"optionsSnapshot": [],
"stripeProductId": null,
"stripePriceId": null,
"stripeTaxCodeId": null
}
variantIdmust belong to your store's catalog — unrecognized variantIds causevalidation_failed- All amounts in
lineItemsmust be>= 0— negative amounts causevalidation_failed orderItemsandlineItemsmust contain at least 1 element
Security — Signing the Response
Webround validates your response using the same logic you use to validate its request.
You must sign your response body excluding the signature field, using your timestamp (you can reuse the one received or generate a new one):
bodyHash = SHA256(JSON.stringify(response without signature))
sigPayload = "{version}.{storeId}.{timestamp}.{bodyHash}"
signature = HMAC-SHA256(secret, sigPayload)
If the signature is invalid, or if your timestamp is outside the 30-second window, Webround will treat the response as failed and apply the behavior configured in onError.
Implementation Example
const HOOK_VERSION = 1;
const TOLERANCE_MS = 30_000;
export default {
async fetch(request, env) {
if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 });
const body = await request.json();
const secret = env.CHECKOUT_HOOK_SECRET;
// 1. Validate inbound signature
if (!verifySignature(body, secret)) {
return new Response('Unauthorized', { status: 401 });
}
// 2. Validate timestamp
if (Math.abs(Date.now() - body.timestamp) > TOLERANCE_MS) {
return new Response('Unauthorized', { status: 401 });
}
const { storeId, timestamp, items, lineItems } = body;
const customer = body.customer;
// 3. Your custom logic
// Example: 10% discount for customers with a VAT code
if (!customer?.vatCode) {
return respond(body, items, lineItems, secret);
}
const discountedItems = items.map(item => applyDiscount(item, 0.10));
const discountedLineItems = lineItems.map(li => ({
...li,
price_data: {
...li.price_data,
unit_amount: Math.round(li.price_data.unit_amount * 0.90),
}
}));
return respond(body, discountedItems, discountedLineItems, secret, {
vatDiscount: true,
discountApplied: '10%'
});
}
};
function respond(incomingBody, orderItems, lineItems, secret, additionalData) {
const { storeId, timestamp } = incomingBody;
const payload = {
version: HOOK_VERSION,
storeId,
timestamp,
orderItems,
lineItems,
...(additionalData ? { additionalData } : {}),
};
const signature = buildSignature(payload, secret, storeId, timestamp);
return new Response(JSON.stringify({ ...payload, signature }), {
headers: { 'Content-Type': 'application/json' },
});
}
function verifySignature(payload, secret) {
const { signature, ...rest } = payload;
const bodyHash = sha256(JSON.stringify(rest));
const sigPayload = `${HOOK_VERSION}.${payload.storeId}.${payload.timestamp}.${bodyHash}`;
const expected = hmacSha256(secret, sigPayload);
return timingSafeEqual(expected, signature);
}
function buildSignature(payload, secret, storeId, timestamp) {
const { signature: _, ...rest } = payload;
const bodyHash = sha256(JSON.stringify(rest));
const sigPayload = `${HOOK_VERSION}.${storeId}.${timestamp}.${bodyHash}`;
return hmacSha256(secret, sigPayload);
}
Logs and Monitoring
Every execution of the Checkout Hook is recorded. You can consult the logs in the Checkout Hook section of your Webround Commerce admin panel.
For each call, the following are available:
| Field | Description |
|---|---|
| Status | ok, error, timeout, validation_failed |
| Duration | Response time in milliseconds |
| Fallback Applied | Whether Webround used original data instead of yours |
| Request Payload | The full payload sent to your endpoint |
| Response Payload | The response received from your endpoint |
| Error | Error message in case of failure |
Error Scenarios
| Scenario | Behavior |
|---|---|
| Endpoint unreachable | error → onError |
| Timeout exceeded | timeout → onError |
| HTTP non-200 | error → onError |
| Invalid response signature | validation_failed → onError |
| Expired response timestamp | validation_failed → onError |
| Invalid response schema | validation_failed → onError |
| Unknown variantId | validation_failed → onError |
| Negative amount in lineItem | validation_failed → onError |
With onError: passthrough, in all above cases, the checkout proceeds with the original data calculated by Webround. With onError: abort, the checkout is blocked.
Final Considerations
- Technical Freedom: The endpoint can be implemented with any stack (Node.js, Go, Python, etc.) and hosted on any infrastructure. The only requirement is compliance with HTTPS protocol, JSON structure, and response times.
- Single Source of Truth: Only one hook is allowed per store. This ensures that any data modifications come from a single endpoint, avoiding conflicts between competing pricing logics.
- External Logic: The system is designed to integrate data residing in CRMs, ERPs, or proprietary databases. Webround delegates the final decision on price and order composition to your system, acting only as the executor towards Stripe Checkout.
- Failure Management: Choosing between
passthroughandabortallows for a balance between service continuity and custom logic precision. - End-to-End Security: Bidirectional signing ensures payload integrity for both inbound and outbound traffic, and timestamp validation prevents replay attacks.
Checkout is the most critical element of an e-commerce system. Drastically slowing down the system's response at this stage can lead to site abandonment and, consequently, order loss. We recommend keeping a timeout wide enough to allow for response delays, but also implementing your hook with technologies that respond quickly.
If your system suffers from cold starts, we suggest keeping it warm or using a system that does not have these issues, such as Cloudflare Workers, which balance serverless flexibility with Edge Computing responsiveness.