Skip to content

Webhooks

View as Markdown

Beauty Router sends events to your server as webhooks: product match updates, verification results, new orders, and order updates.

Your salon app has one HTTPS webhook URL, on a public address. Beauty Router does not follow redirects. Set the URL in the salon app admin area, and generate a webhook secret there. Both are required before users can log in. The secret verifies the signature of each event. The admin area shows it once, so store it on your server right away.

Webhook secrets work like app tokens. You can have up to 5 live secrets at the same time, each one can have a lifetime, and you can revoke one. The last live secret cannot have a lifetime, and it cannot be revoked. Beauty Router sends a reminder 30 days and 7 days before a secret expires.

Two secrets can work side by side, so you can replace a secret with no downtime:

  1. Generate a new secret in the admin area.
  2. Put the new secret on your server.
  3. Limit the lifetime of the old secret, so that it expires once all your servers use the new one.

If a secret leaks, generate a new secret and put it on your server, then revoke the leaked one in the admin area. It stops working at once.

Each event is a POST request with a JSON body:

{
"id": "event-7Zx1cVb9Nm",
"type": "product_match.updated",
"createdAt": "2026-10-27T16:40:33Z",
"data": {
"id": "match-9Lk4jHg7Fd",
"externalId": "inv-10517",
"status": "needs_reply",
"question": "Is this product from Jadewell or Amberlin?",
"createdAt": "2026-10-27T09:14:02Z",
"updatedAt": "2026-10-27T16:40:31Z"
}
}
Field Description
id The event ID, such as event-7Zx1cVb9Nm.
type The event type. See the table below.
createdAt When the event happened.
data The object the event is about.
Type When it is sent data
product_match.updated A product match was created as matched, or changed status. The product match
company.verification_updated The verification status changed, or a change after verification was approved or declined. { companyId, verificationStatus, pendingChanges }
order.created An order was created and its charge ran. status is placed or payment_failed, or pending_verification when the charge waits for verification. The order
order.updated The status, tracking, delivery address, or other order details changed, such as payment_failed to placed. The order

A company.verification_updated event looks like this:

{
"id": "event-2Rb6nMq8Wc",
"type": "company.verification_updated",
"createdAt": "2026-10-27T13:20:11Z",
"data": {
"companyId": "loc-7Hk2pQx9Lm",
"verificationStatus": "verified",
"pendingChanges": null
}
}

companyId is the company.id that POST /v1/login returned for that company. The order in order.created and order.updated has the same companyId, and userId, the user.id of the user who placed it. See Orders.

Each request has a Beauty-Router-Signature header:

Beauty-Router-Signature: t=1793200437,v1=5f2b8c0e4d6a7b1c9e3f0a2d4b6c8e1f3a5b7d9c0e2f4a6b8d1c3e5f7a9b0c2d
  • t is the time Beauty Router signed the request, in Unix seconds.
  • v1 is the hex HMAC-SHA256 of <t>.<raw body>, with your webhook secret as the key.

While several secrets are live, the header has one v1 per live secret. Accept the event if any v1 matches:

Beauty-Router-Signature: t=1793200437,v1=5f2b8c0e4d6a7b1c9e3f0a2d4b6c8e1f3a5b7d9c0e2f4a6b8d1c3e5f7a9b0c2d,v1=c81e7a2f60d94b3e15f8a7c2d09b6e4f3a1d5c7e92b08f64d3a1e7c50b9f2d86

To verify a request:

  1. Read t and every v1 from the header.
  2. Reject the request when t is more than 5 minutes old or more than 5 minutes in the future.
  3. Compute the HMAC-SHA256 of t, a period, and the raw request body, with your webhook secret.
  4. Compare the result with each v1 in constant time. Accept the event if any of them matches.

Use the raw body exactly as it arrived. A body that was parsed and serialized again has a different signature.

This example uses Express:

import crypto from "node:crypto";
import express from "express";
const webhookSecret = process.env.BEAUTY_ROUTER_WEBHOOK_SECRET;
const maxSkewSeconds = 5 * 60;
function isValidSignature(header, rawBody) {
const parts = (header ?? "").split(",").map((part) => part.trim().split("="));
const timestamp = parts.find(([key]) => key === "t")?.[1];
const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? "");
if (!/^\d+$/.test(timestamp ?? "") || Math.abs(Date.now() / 1000 - Number(timestamp)) > maxSkewSeconds) {
return false;
}
const expected = crypto.createHmac("sha256", webhookSecret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((signature) => {
const received = Buffer.from(signature, "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post("/beauty-router/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req.get("Beauty-Router-Signature"), req.body)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
// Skip the event when you already saved its id. Otherwise save it and handle it after you respond.
res.sendStatus(200);
});
app.listen(3000);

Respond with a 2xx status within 10 seconds. Do slow work after you respond.

When your server does not respond with 2xx in time, Beauty Router retries with backoff for up to 24 hours. Delivery is at least once, so the same event can arrive more than once. Save the event id and skip an event you already handled.

After 24 hours Beauty Router stops trying. To catch up on orders, read GET /v1/orders. It takes a user token, so log in once per company.

Events can arrive out of order. Compare updatedAt on the order or the product match with the one you saved, and keep the newer one.