Skip to main content
Version: Thelia 3

Webhooks

Added in Thelia 3.3.

A shop can notify other systems of what happens in it: an ERP that wants every new order, a CRM that wants every new customer. A webhook is an HTTPS request Thelia sends, on its own, to an address you give it, each time one of the events you chose happens. The request is signed, so the receiver knows it comes from your shop and was not altered.

Both tables (webhook_subscription, webhook_delivery) arrive empty on a shop that updates: nothing is sent until a subscription exists.

Subscribing​

A subscription is an address, the events it asks for, a signing secret, a content mode and a state.

FieldMeaning
titleA name for you, up to 255 characters.
urlWhere the requests go. https only, never a private or internal address (see Addresses).
eventsThe names of the events it asks for, at least one (see Events).
payloadModefull (default) sends the whole object, identifiers sends only its id.
statusactive (default) or inactive. A subscription can also be in error: see Delivery.

The secret is generated by the shop. It is shown once, when the subscription is created and when it is replaced, and never read back: copy it then. A lost secret is replaced, not recovered.

The subscriptions answer to their own permission, admin.configuration.webhook, granted to no profile by default: an account that could create an address could send the orders of the shop to it. The superadministrator always has it.

In the back office​

Configuration, Webhooks lists the subscriptions, creates, edits and deletes them, replaces a secret, sends a signed test request, and shows the delivery log with a replay button for a failed delivery.

With the API​

OperationEndpoint
List, createGET, POST /api/admin/webhook-subscriptions
Read, update, deleteGET, PATCH, DELETE /api/admin/webhook-subscriptions/{id}
Replace the secretPOST /api/admin/webhook-subscriptions/{id}/secret
List, read the delivery logGET /api/admin/webhook-deliveries, GET /api/admin/webhook-deliveries/{id}
curl -X POST https://shop.example.com/api/admin/webhook-subscriptions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "ERP",
"url": "https://erp.example.com/hooks/thelia",
"events": ["order.created", "order.paid"]
}'

The answer carries the subscription and, this once, its secret:

{
"id": 3,
"title": "ERP",
"url": "https://erp.example.com/hooks/thelia",
"events": ["order.created", "order.paid"],
"payloadMode": "full",
"status": "active",
"failureReason": null,
"failedAt": null,
"secret": "b1c0…64 hexadecimal characters…",
"createdAt": "2026-10-20T11:00:00+02:00",
"updatedAt": "2026-10-20T11:00:00+02:00"
}
  • An update only changes what it carries: PATCH {"status": "inactive"} switches the subscription off and leaves the rest alone. Writing active to a subscription in error puts it back to work and clears the failure. error cannot be written.
  • The secret is not an input. POST …/secret replaces it and answers with the new one; the old one stops signing at once.
  • The address and the event names are checked by the same rule as in the back office. A refusal answers 422 and nothing is stored.
  • The delivery log is read only. It shows the outcome of each request (subscriptionId, event, deliveryUuid, status, attempts, responseCode, durationMs, deliveredAt, replayedAt, createdAt) and filters on subscriptionId, event, status, deliveryUuid and on createdAt[after] / createdAt[before]. It does not show the body that was sent nor the answer of the receiver: the first holds personal data, the second whatever the receiver chose to echo back. Pages are 25 items long and capped at 50.

Events​

EventSent whenBody
order.createdan order is placed, or created through the admin APIthe order
order.status_changedthe status of an order changesthe order
order.paidan order comes into a paid status (paid, processing, sent)the order
customer.createda customer account is createdthe customer
product.updateda product is saved with a changethe product

There is no stock event: the core takes stock with a single atomic statement that dispatches nothing.

A module adds its own events by appending name => ['resource' => ResourceClass::class, 'groups' => [...]] to the thelia.webhook.events container parameter, and calls WebhookPublisher::publish($name, $model) from its own listener.

The request​

Every request is a POST with a JSON body and three headers:

HeaderContent
X-Thelia-Webhook-IdA unique identifier for this delivery, the same on every retry.
X-Thelia-Webhook-TimestampThe Unix time the request was signed.
X-Thelia-Webhook-Signaturev1= followed by the signature (see Verifying the signature).

The body is an envelope:

KeyContent
idThe identifier of the delivery, equal to the X-Thelia-Webhook-Id header.
typeThe event name.
created_atWhen the event was published, ISO 8601.
dataThe object.

With payloadMode set to full, data is the object as the admin API shows it (the same properties, the same nesting), with one difference: the API leaves out a property that is null, a webhook keeps it as null, so the keys a receiver reads never appear or disappear from one event to the next. Properties added by the modules installed on the shop are part of it, as they are of the admin API (the Siret and ProductLibraryImagesAddon keys below come from two modules).

With identifiers, data is only {"id": 42}, for a receiver that fetches what it needs through the API.

Personal data

A body in full mode carries customer and address data: it is personal data sent to a third party, which the register of processing of the shop has to list. The identifiers mode exists for a receiver that does not need it. The deliveries keep the body that was sent, so the same data, in clear, and maintenance:purge removes them after webhook_delivery_retention_days days (30 by default, --dry-run counts them).

Examples​

Each example is a real request, produced by the code of the shop on test data, with the secret whsec_example_secret. The body is indented to be read: the signature covers the body as it was sent, without that indentation.

order.created​

Published when an order is placed, just before the payment module takes over, and when an order really placed is created from the back office. The body is the whole order: lines, addresses, delivery and payment modules.

Full request
POST /your/endpoint
Content-Type: application/json
X-Thelia-Webhook-Id: 40b3ae9c-332e-4c99-8ff8-5a4c3c6ae232
X-Thelia-Webhook-Timestamp: 1792487295
X-Thelia-Webhook-Signature: v1=227f4f60673b5d2e0434861cfa4f3ebd9a84ae7c774aa32283e35c11d377d6c8
{
"id": "40b3ae9c-332e-4c99-8ff8-5a4c3c6ae232",
"type": "order.created",
"created_at": "2026-10-20T11:08:15+02:00",
"data": {
"id": 1201,
"ref": "ORD000000000001",
"invoiceDate": null,
"currencyRate": 1,
"createdAt": "2026-10-20T11:08:15+02:00",
"updatedAt": "2026-10-20T11:08:15+02:00",
"discount": 0,
"customerDiscountRate": 0,
"postage": 5,
"postageTax": 0,
"postageTaxRuleTitle": null,
"transactionRef": null,
"deliveryRef": null,
"deliveryTrackingUrl": null,
"invoiceRef": null,
"totalAmount": 103.5,
"totalAmountWithoutTaxes": 98.5,
"totalAmountWithTaxBeforeDiscount": 98.5,
"amountDiscountWithTaxes": 0,
"totalAmountWithTaxesAfterDiscount": 98.5,
"totalShippingWithTaxes": 5,
"vatExempted": false,
"orderProducts": [
{
"id": 1399,
"productRef": "SHIRT-LIN",
"productSaleElementsRef": "ORDER-LINE-PSE-5",
"productSaleElementsId": 1,
"title": "Linen shirt",
"quantity": 2,
"price": 40,
"promoPrice": 40,
"unitTaxedPrice": null,
"wasNew": true,
"wasInPromo": false,
"weight": null,
"eanCode": null,
"mpn": null,
"orderProductTaxes": []
},
{
"id": 1400,
"productRef": "TOTE-CNV",
"productSaleElementsRef": "ORDER-LINE-PSE-6",
"productSaleElementsId": 1,
"title": "Canvas tote",
"quantity": 1,
"price": 18.5,
"promoPrice": 18.5,
"unitTaxedPrice": null,
"wasNew": true,
"wasInPromo": false,
"weight": null,
"eanCode": null,
"mpn": null,
"orderProductTaxes": []
}
],
"orderShipments": [],
"orderCoupons": [],
"invoiceOrderAddress": {
"id": 2417,
"firstname": "John",
"lastname": "Doe",
"address1": "2 Main Street",
"address2": "",
"address3": "",
"zipcode": "75001",
"city": "Paris",
"phone": null,
"cellphone": null,
"company": null,
"siret": null,
"vatNumber": null,
"vatVerifiedAt": null,
"vatVerifiedName": null,
"vatExemptedAmount": null,
"customerTitle": {
"id": 1
},
"country": {
"id": 1,
"isocode": "4",
"isoalpha2": "AF",
"isoalpha3": "AFG"
},
"state": null
},
"deliveryOrderAddress": {
"id": 2418,
"firstname": "John",
"lastname": "Doe",
"address1": "3 Main Street",
"address2": "",
"address3": "",
"zipcode": "75001",
"city": "Paris",
"phone": null,
"cellphone": null,
"company": null,
"siret": null,
"vatNumber": null,
"vatVerifiedAt": null,
"vatVerifiedName": null,
"vatExemptedAmount": null,
"customerTitle": {
"id": 1
},
"country": {
"id": 1,
"isocode": "4",
"isoalpha2": "AF",
"isoalpha3": "AFG"
},
"state": null
},
"paymentModule": {
"id": 5,
"code": "Cheque",
"category": "payment",
"type": "3",
"version": "3.1.0",
"activate": true,
"fullNamespace": "Cheque\\Cheque",
"hidden": false,
"position": 5,
"mandatory": false,
"createdAt": "2026-10-20T10:53:11+02:00",
"updatedAt": null
},
"paymentModuleTitle": null,
"deliveryModule": {
"id": 10,
"code": "CustomDelivery",
"category": "delivery",
"type": "2",
"version": "4.2.0",
"activate": true,
"fullNamespace": "CustomDelivery\\CustomDelivery",
"hidden": false,
"position": 10,
"mandatory": false,
"createdAt": "2026-10-20T10:53:11+02:00",
"updatedAt": null
},
"deliveryModuleTitle": null,
"giftMessage": null,
"deliveryDate": null,
"deliverySlotStart": null,
"deliverySlotEnd": null,
"orderStatus": {
"id": 1,
"code": "not_paid",
"color": "#dc3545"
},
"customer": {
"id": 1764,
"ref": "CUS000000000001",
"firstname": "John",
"lastname": "Doe",
"email": "customer-1@test.com"
},
"currency": {
"id": 1,
"code": "EUR",
"isocodeNumeric": "978",
"symbol": "€"
},
"lang": {
"id": 1
},
"cartId": 1959
}
}

order.status_changed​

Published each time the status of an order changes to a different one. The body is the order as it is after the change.

Full request
POST /your/endpoint
Content-Type: application/json
X-Thelia-Webhook-Id: 37e840c4-3ec5-424e-b4bf-a086861e9dcd
X-Thelia-Webhook-Timestamp: 1792487295
X-Thelia-Webhook-Signature: v1=5984e0833c8b8cf9bf52e2a4d80ee901931ea09bbba973c2fc04ec05231ffa7c
{
"id": "37e840c4-3ec5-424e-b4bf-a086861e9dcd",
"type": "order.status_changed",
"created_at": "2026-10-20T11:08:15+02:00",
"data": {
"id": 1201,
"ref": "ORD000000000001",
"invoiceDate": null,
"currencyRate": 1,
"createdAt": "2026-10-20T11:08:15+02:00",
"updatedAt": "2026-10-20T11:08:15+02:00",
"discount": 0,
"customerDiscountRate": 0,
"postage": 5,
"postageTax": 0,
"postageTaxRuleTitle": null,
"transactionRef": null,
"deliveryRef": null,
"deliveryTrackingUrl": null,
"invoiceRef": null,
"totalAmount": 103.5,
"totalAmountWithoutTaxes": 98.5,
"totalAmountWithTaxBeforeDiscount": 98.5,
"amountDiscountWithTaxes": 0,
"totalAmountWithTaxesAfterDiscount": 98.5,
"totalShippingWithTaxes": 5,
"vatExempted": false,
"orderProducts": [
{
"id": 1399,
"productRef": "SHIRT-LIN",
"productSaleElementsRef": "ORDER-LINE-PSE-5",
"productSaleElementsId": 1,
"title": "Linen shirt",
"quantity": 2,
"price": 40,
"promoPrice": 40,
"unitTaxedPrice": null,
"wasNew": true,
"wasInPromo": false,
"weight": null,
"eanCode": null,
"mpn": null,
"orderProductTaxes": []
},
{
"id": 1400,
"productRef": "TOTE-CNV",
"productSaleElementsRef": "ORDER-LINE-PSE-6",
"productSaleElementsId": 1,
"title": "Canvas tote",
"quantity": 1,
"price": 18.5,
"promoPrice": 18.5,
"unitTaxedPrice": null,
"wasNew": true,
"wasInPromo": false,
"weight": null,
"eanCode": null,
"mpn": null,
"orderProductTaxes": []
}
],
"orderShipments": [],
"orderCoupons": [],
"invoiceOrderAddress": {
"id": 2417,
"firstname": "John",
"lastname": "Doe",
"address1": "2 Main Street",
"address2": "",
"address3": "",
"zipcode": "75001",
"city": "Paris",
"phone": null,
"cellphone": null,
"company": null,
"siret": null,
"vatNumber": null,
"vatVerifiedAt": null,
"vatVerifiedName": null,
"vatExemptedAmount": null,
"customerTitle": {
"id": 1
},
"country": {
"id": 1,
"isocode": "4",
"isoalpha2": "AF",
"isoalpha3": "AFG"
},
"state": null
},
"deliveryOrderAddress": {
"id": 2418,
"firstname": "John",
"lastname": "Doe",
"address1": "3 Main Street",
"address2": "",
"address3": "",
"zipcode": "75001",
"city": "Paris",
"phone": null,
"cellphone": null,
"company": null,
"siret": null,
"vatNumber": null,
"vatVerifiedAt": null,
"vatVerifiedName": null,
"vatExemptedAmount": null,
"customerTitle": {
"id": 1
},
"country": {
"id": 1,
"isocode": "4",
"isoalpha2": "AF",
"isoalpha3": "AFG"
},
"state": null
},
"paymentModule": {
"id": 5,
"code": "Cheque",
"category": "payment",
"type": "3",
"version": "3.1.0",
"activate": true,
"fullNamespace": "Cheque\\Cheque",
"hidden": false,
"position": 5,
"mandatory": false,
"createdAt": "2026-10-20T10:53:11+02:00",
"updatedAt": null
},
"paymentModuleTitle": null,
"deliveryModule": {
"id": 10,
"code": "CustomDelivery",
"category": "delivery",
"type": "2",
"version": "4.2.0",
"activate": true,
"fullNamespace": "CustomDelivery\\CustomDelivery",
"hidden": false,
"position": 10,
"mandatory": false,
"createdAt": "2026-10-20T10:53:11+02:00",
"updatedAt": null
},
"deliveryModuleTitle": null,
"giftMessage": null,
"deliveryDate": null,
"deliverySlotStart": null,
"deliverySlotEnd": null,
"orderStatus": {
"id": 1,
"code": "not_paid",
"color": "#dc3545"
},
"customer": {
"id": 1764,
"ref": "CUS000000000001",
"firstname": "John",
"lastname": "Doe",
"email": "customer-1@test.com"
},
"currency": {
"id": 1,
"code": "EUR",
"isocodeNumeric": "978",
"symbol": "€"
},
"lang": {
"id": 1
},
"cartId": 1959
}
}

order.paid​

Published once when an order comes into the statuses the shop counts as paid (paid, processing, sent) from a status outside them, right after order.status_changed for the same change. A move between those statuses does not publish it again.

Full request
POST /your/endpoint
Content-Type: application/json
X-Thelia-Webhook-Id: 2ddf7ade-0ba8-4015-810d-8fb5736a7998
X-Thelia-Webhook-Timestamp: 1792487295
X-Thelia-Webhook-Signature: v1=713bf5cb003af0ac70be02a79d748e37e4c6eed2b833cbc9e402c6e40ba2f382
{
"id": "2ddf7ade-0ba8-4015-810d-8fb5736a7998",
"type": "order.paid",
"created_at": "2026-10-20T11:08:15+02:00",
"data": {
"id": 1201,
"ref": "ORD000000000001",
"invoiceDate": null,
"currencyRate": 1,
"createdAt": "2026-10-20T11:08:15+02:00",
"updatedAt": "2026-10-20T11:08:15+02:00",
"discount": 0,
"customerDiscountRate": 0,
"postage": 5,
"postageTax": 0,
"postageTaxRuleTitle": null,
"transactionRef": null,
"deliveryRef": null,
"deliveryTrackingUrl": null,
"invoiceRef": null,
"totalAmount": 103.5,
"totalAmountWithoutTaxes": 98.5,
"totalAmountWithTaxBeforeDiscount": 98.5,
"amountDiscountWithTaxes": 0,
"totalAmountWithTaxesAfterDiscount": 98.5,
"totalShippingWithTaxes": 5,
"vatExempted": false,
"orderProducts": [
{
"id": 1399,
"productRef": "SHIRT-LIN",
"productSaleElementsRef": "ORDER-LINE-PSE-5",
"productSaleElementsId": 1,
"title": "Linen shirt",
"quantity": 2,
"price": 40,
"promoPrice": 40,
"unitTaxedPrice": null,
"wasNew": true,
"wasInPromo": false,
"weight": null,
"eanCode": null,
"mpn": null,
"orderProductTaxes": []
},
{
"id": 1400,
"productRef": "TOTE-CNV",
"productSaleElementsRef": "ORDER-LINE-PSE-6",
"productSaleElementsId": 1,
"title": "Canvas tote",
"quantity": 1,
"price": 18.5,
"promoPrice": 18.5,
"unitTaxedPrice": null,
"wasNew": true,
"wasInPromo": false,
"weight": null,
"eanCode": null,
"mpn": null,
"orderProductTaxes": []
}
],
"orderShipments": [],
"orderCoupons": [],
"invoiceOrderAddress": {
"id": 2417,
"firstname": "John",
"lastname": "Doe",
"address1": "2 Main Street",
"address2": "",
"address3": "",
"zipcode": "75001",
"city": "Paris",
"phone": null,
"cellphone": null,
"company": null,
"siret": null,
"vatNumber": null,
"vatVerifiedAt": null,
"vatVerifiedName": null,
"vatExemptedAmount": null,
"customerTitle": {
"id": 1
},
"country": {
"id": 1,
"isocode": "4",
"isoalpha2": "AF",
"isoalpha3": "AFG"
},
"state": null
},
"deliveryOrderAddress": {
"id": 2418,
"firstname": "John",
"lastname": "Doe",
"address1": "3 Main Street",
"address2": "",
"address3": "",
"zipcode": "75001",
"city": "Paris",
"phone": null,
"cellphone": null,
"company": null,
"siret": null,
"vatNumber": null,
"vatVerifiedAt": null,
"vatVerifiedName": null,
"vatExemptedAmount": null,
"customerTitle": {
"id": 1
},
"country": {
"id": 1,
"isocode": "4",
"isoalpha2": "AF",
"isoalpha3": "AFG"
},
"state": null
},
"paymentModule": {
"id": 5,
"code": "Cheque",
"category": "payment",
"type": "3",
"version": "3.1.0",
"activate": true,
"fullNamespace": "Cheque\\Cheque",
"hidden": false,
"position": 5,
"mandatory": false,
"createdAt": "2026-10-20T10:53:11+02:00",
"updatedAt": null
},
"paymentModuleTitle": null,
"deliveryModule": {
"id": 10,
"code": "CustomDelivery",
"category": "delivery",
"type": "2",
"version": "4.2.0",
"activate": true,
"fullNamespace": "CustomDelivery\\CustomDelivery",
"hidden": false,
"position": 10,
"mandatory": false,
"createdAt": "2026-10-20T10:53:11+02:00",
"updatedAt": null
},
"deliveryModuleTitle": null,
"giftMessage": null,
"deliveryDate": null,
"deliverySlotStart": null,
"deliverySlotEnd": null,
"orderStatus": {
"id": 1,
"code": "not_paid",
"color": "#dc3545"
},
"customer": {
"id": 1764,
"ref": "CUS000000000001",
"firstname": "John",
"lastname": "Doe",
"email": "customer-1@test.com"
},
"currency": {
"id": 1,
"code": "EUR",
"isocodeNumeric": "978",
"symbol": "€"
},
"lang": {
"id": 1
},
"cartId": 1959
}
}

customer.created​

Published when a customer account is created: a sign-up from the storefront, from the back office, or a customer created through the API. A guest checkout and an update of an account publish nothing.

Full request
POST /your/endpoint
Content-Type: application/json
X-Thelia-Webhook-Id: bb511faa-1056-4266-b633-7e493c242a2e
X-Thelia-Webhook-Timestamp: 1792487295
X-Thelia-Webhook-Signature: v1=f7f3c83b4913f84038e31952ea4c23a5040ec7f34fa2404095de20d7f77775b9
{
"id": "bb511faa-1056-4266-b633-7e493c242a2e",
"type": "customer.created",
"created_at": "2026-10-20T11:08:15+02:00",
"data": {
"id": 1764,
"customerTitle": {
"id": 1
},
"lang": null,
"ref": "CUS000000000001",
"firstname": "John",
"lastname": "Doe",
"email": "customer-1@test.com",
"reseller": null,
"discount": 0,
"createdAt": "2026-10-20T11:08:15+02:00",
"updatedAt": "2026-10-20T11:08:15+02:00",
"addresses": [],
"Siret": []
}
}

product.updated​

Published when a product is saved with a change: the product row, its text in the edited language, its default combination or its default category. A save that changes nothing publishes nothing. A price, a weight or a stock quantity goes through the combination events and does not publish it. A product written through the API is published on every write, even one that changes nothing: the API has no state from before the write to compare with.

Full request
POST /your/endpoint
Content-Type: application/json
X-Thelia-Webhook-Id: 70b0c7dd-7ed3-4d88-bfb2-9f1c5b12977d
X-Thelia-Webhook-Timestamp: 1792487295
X-Thelia-Webhook-Signature: v1=1962c13f3be161d01c17e8d335888d78b3d8ba13b8d72a692b95cba72166ee72
{
"id": "70b0c7dd-7ed3-4d88-bfb2-9f1c5b12977d",
"type": "product.updated",
"created_at": "2026-10-20T11:08:15+02:00",
"data": {
"id": 2091,
"taxRule": {
"id": 1
},
"ref": "PROD-8",
"visible": true,
"position": 1,
"template": null,
"brand": null,
"virtual": false,
"createdAt": "2026-10-20T11:08:15+02:00",
"updatedAt": "2026-10-20T11:08:15+02:00",
"productCategories": [
{
"category": {
"id": 1774
},
"defaultCategory": true,
"position": 1
}
],
"productSaleElements": [
{
"id": 2725,
"ref": "PROD-8",
"quantity": 0,
"visible": true,
"promo": false,
"newness": false,
"weight": 0,
"isDefault": true,
"eanCode": "",
"mpn": null,
"manufacturerBrand": null,
"productPrices": [
{
"currency": {
"id": 1,
"code": "EUR",
"isocodeNumeric": "978",
"symbol": "€"
},
"price": 10,
"promoPrice": 10
}
],
"attributeCombinations": []
}
],
"featureProducts": [],
"productAssociatedContents": [],
"i18ns": [],
"publicUrl": "http://localhost/?view=product&lang=en_US&product_id=2091",
"ProductLibraryImagesAddon": {
"libraryImages": []
}
}
}

Verifying the signature​

Anyone who knows the address could post to it. Check the signature before trusting a request.

The signature is v1= followed by the hexadecimal HMAC-SHA256 of <timestamp>.<raw body>, computed with the secret of the subscription. Three rules:

  1. Use the raw body, byte for byte, as received. Decoding the JSON and encoding it again changes the bytes and breaks the signature.
  2. Compare in constant time (hash_equals, crypto.timingSafeEqual), never with ==.
  3. Refuse a timestamp that is too far from now, five minutes at most, so a request that was captured cannot be replayed later. The timestamp is part of what is signed.

PHP​

function verifyTheliaWebhook(string $secret, string $rawBody, string $timestamp, string $signature, ?int $now = null, int $tolerance = 300): bool
{
if (!ctype_digit($timestamp)) {
return false;
}

if (abs(($now ?? time()) - (int) $timestamp) > $tolerance) {
return false;
}

$expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

return hash_equals($expected, $signature);
}

$valid = verifyTheliaWebhook(
$secret,
file_get_contents('php://input'),
$_SERVER['HTTP_X_THELIA_WEBHOOK_TIMESTAMP'] ?? '',
$_SERVER['HTTP_X_THELIA_WEBHOOK_SIGNATURE'] ?? '',
);

if (!$valid) {
http_response_code(401);
exit;
}

Node.js​

const crypto = require('crypto');

function verifyTheliaWebhook(secret, rawBody, timestamp, signature, now, tolerance) {
now = now === undefined ? Math.floor(Date.now() / 1000) : now;
tolerance = tolerance === undefined ? 300 : tolerance;

if (!/^[0-9]+$/.test(timestamp)) {
return false;
}

if (Math.abs(now - Number(timestamp)) > tolerance) {
return false;
}

const expected = Buffer.from(
'v1=' + crypto.createHmac('sha256', secret).update(timestamp + '.' + rawBody).digest('hex')
);
const received = Buffer.from(String(signature));

return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}

With Express, keep the raw body: app.post('/hooks/thelia', express.raw({ type: 'application/json' }), (req, res) => { … req.body.toString('utf8') … }). A body parsed by express.json() is no longer the raw body.

A request to check your code against​

Secret whsec_example_secret, timestamp 1792487295. Both functions above answer true for this body and signature when now is set to the timestamp:

{"id":"bb511faa-1056-4266-b633-7e493c242a2e","type":"customer.created","created_at":"2026-10-20T11:08:15+02:00","data":{"id":1764,"customerTitle":{"id":1},"lang":null,"ref":"CUS000000000001","firstname":"John","lastname":"Doe","email":"customer-1@test.com","reseller":null,"discount":0,"createdAt":"2026-10-20T11:08:15+02:00","updatedAt":"2026-10-20T11:08:15+02:00","addresses":[],"Siret":[]}}
v1=f7f3c83b4913f84038e31952ea4c23a5040ec7f34fa2404095de20d7f77775b9

The same body with a space added at the end must be refused.

Delivery​

  • Answer fast, with a 2xx. Thelia waits up to 5 seconds for the receiver to start answering and 10 seconds in all, follows no redirect, and counts any other answer, or no answer, as a failure. Do the real work after answering.
  • A delivery is at least once, never exactly once. A retry carries the same X-Thelia-Webhook-Id: keep the identifiers you have handled and drop a duplicate.
  • The order of arrival is not guaranteed. A retry of an old order.status_changed can reach the receiver after a newer one. created_at of the body tells which is older.
  • Retries need a queue. The delivery is a job (DeliverWebhook, routed to async), so with a queue and a worker a slow receiver never holds back the order being saved. With a queue and a worker running, a failed attempt is retried after 1 minute, 5 minutes, 30 minutes and 2 hours (five attempts in all). Without a queue the job runs in the request that raised the event: a receiver that stays silent holds that request for up to 10 seconds per subscription, a failure is not retried and the subscription stays active. Configure a queue and run a worker before relying on webhooks in production; the subscription list of the back office warns while none is configured.
  • A request that tests your receiver is not an event. The test button of the back office sends a signed webhook.test request, logged like any delivery under that name; it is not counted in the last successful delivery of a subscription.
  • A webhook raised inside a transaction. With a queue, a message that a worker reads before the transaction of the API write commits is looked for again a few times instead of being dropped. Without a queue the request leaves before the commit: a rollback that follows leaves the receiver with an event that did not happen.
  • A subscription whose retries ran out goes to error and stops receiving. Its failureReason says which delivery failed. An administrator enables it again once the receiver is fixed; a failed delivery can be replayed from the delivery log of the back office. A replay that fails does not put the subscription back in error. A replay is one attempt: it keeps the attempts already made in attempts, and a delivery that was queued when its subscription was turned off, or went to error, is recorded as cancelled and never sent.
  • Replacing the secret does not touch the deliveries already waiting for a retry. A retry is signed with the secret in force when it is sent, so a receiver that has not yet been given the new secret refuses those retries.

Addresses​

The address is entered by an administrator, so it is checked as an attack vector. https only (the thelia.webhook.allow_insecure_urls parameter allows http, for a development shop), no credentials in the address, no localhost or internal suffix (.local, .internal…), and no private, reserved, loopback, link-local, shared, multicast or documentation address, in any spelling (127.1, decimal, hexadecimal, IPv6, NAT64, 6to4).

The check reads the spelling of the address when the subscription is saved and when a delivery is sent. It does not resolve names: the HTTP client of the webhooks (thelia.webhook.http_client) is the barrier against a name that points to a private address, and it refuses to connect to a private network whatever the name resolves to.