Webhooks
Beauty Router sends events to your server as webhooks: product match updates, verification results, new orders, and order updates.
Your webhook URL
Section titled “Your webhook URL”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.
Rotate a secret
Section titled “Rotate a secret”Two secrets can work side by side, so you can replace a secret with no downtime:
- Generate a new secret in the admin area.
- Put the new secret on your server.
- 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.
Events
Section titled “Events”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.
Verify the signature
Section titled “Verify the signature”Each request has a Beauty-Router-Signature header:
Beauty-Router-Signature: t=1793200437,v1=5f2b8c0e4d6a7b1c9e3f0a2d4b6c8e1f3a5b7d9c0e2f4a6b8d1c3e5f7a9b0c2dtis the time Beauty Router signed the request, in Unix seconds.v1is 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=c81e7a2f60d94b3e15f8a7c2d09b6e4f3a1d5c7e92b08f64d3a1e7c50b9f2d86To verify a request:
- Read
tand everyv1from the header. - Reject the request when
tis more than 5 minutes old or more than 5 minutes in the future. - Compute the HMAC-SHA256 of
t, a period, and the raw request body, with your webhook secret. - Compare the result with each
v1in 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);This example uses FastAPI:
import hashlibimport hmacimport jsonimport osimport time
from fastapi import FastAPI, HTTPException, Request
WEBHOOK_SECRET = os.environ["BEAUTY_ROUTER_WEBHOOK_SECRET"].encode()MAX_SKEW_SECONDS = 5 * 60
app = FastAPI()
def is_valid_signature(header: str, raw_body: bytes) -> bool: pairs = [part.strip().split("=", 1) for part in header.split(",") if "=" in part] timestamps = [value for key, value in pairs if key == "t"] signatures = [value for key, value in pairs if key == "v1"]
if len(timestamps) != 1 or not timestamps[0].isdecimal(): return False if abs(time.time() - int(timestamps[0])) > MAX_SKEW_SECONDS: return False
signed = timestamps[0].encode() + b"." + raw_body expected = hmac.new(WEBHOOK_SECRET, signed, hashlib.sha256).hexdigest().encode() return any(hmac.compare_digest(expected, signature.encode()) for signature in signatures)
@app.post("/beauty-router/webhooks")async def beauty_router_webhook(request: Request): raw_body = await request.body() if not is_valid_signature(request.headers.get("Beauty-Router-Signature", ""), raw_body): raise HTTPException(status_code=400)
event = json.loads(raw_body) # Skip the event when you already saved its id. Otherwise save it and handle it after you respond. return {"received": True}This example uses Laravel. Register the route in routes/api.php, so CSRF protection does not apply.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;use Illuminate\Http\Response;
class BeautyRouterWebhookController extends Controller{ private const MAX_SKEW_SECONDS = 300;
public function __invoke(Request $request): Response { $rawBody = $request->getContent();
if (! $this->isValidSignature((string) $request->header('Beauty-Router-Signature'), $rawBody)) { abort(400); }
$event = json_decode($rawBody, true); // Skip the event when you already saved its id. Otherwise save it and handle it after you respond.
return response()->noContent(); }
private function isValidSignature(string $header, string $rawBody): bool { $timestamp = null; $signatures = [];
foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') { $timestamp = $value; } elseif ($key === 'v1') { $signatures[] = $value; } }
if ($timestamp === null || ! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > self::MAX_SKEW_SECONDS) { return false; }
$secret = config('services.beauty_router.webhook_secret'); $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } }
return false; }}This example uses Ruby on Rails:
class BeautyRouterWebhooksController < ActionController::API MAX_SKEW_SECONDS = 5 * 60
def create raw_body = request.raw_post
unless valid_signature?(request.headers["Beauty-Router-Signature"].to_s, raw_body) return head :bad_request end
event = JSON.parse(raw_body) # Skip the event when you already saved its id. Otherwise save it and handle it after you respond. head :ok end
private
def valid_signature?(header, raw_body) pairs = header.split(",").map { |part| part.strip.split("=", 2) } timestamps = pairs.select { |key, _| key == "t" }.map { |_, value| value.to_s } signatures = pairs.select { |key, _| key == "v1" }.map { |_, value| value.to_s }
return false unless timestamps.one? && timestamps.first.match?(/\A\d+\z/)
timestamp = timestamps.first return false if (Time.now.to_i - timestamp.to_i).abs > MAX_SKEW_SECONDS
secret = ENV.fetch("BEAUTY_ROUTER_WEBHOOK_SECRET") expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}") signatures.any? { |signature| ActiveSupport::SecurityUtils.secure_compare(expected, signature) } endendThis example uses ASP.NET Core:
using System.Globalization;using System.Security.Cryptography;using System.Text;
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();
var webhookSecret = Encoding.UTF8.GetBytes(builder.Configuration["BeautyRouter:WebhookSecret"]!);const long maxSkewSeconds = 5 * 60;
bool IsValidSignature(string header, byte[] rawBody){ var pairs = header.Split(',', StringSplitOptions.TrimEntries) .Select(part => part.Split('=', 2)) .Where(pair => pair.Length == 2) .ToList();
var timestamp = pairs.FirstOrDefault(pair => pair[0] == "t")?[1]; if (!long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > maxSkewSeconds) { return false; }
var signed = Encoding.UTF8.GetBytes($"{timestamp}.").Concat(rawBody).ToArray(); var hash = HMACSHA256.HashData(webhookSecret, signed); var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hash).ToLowerInvariant());
return pairs .Where(pair => pair[0] == "v1") .Any(pair => CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(pair[1])));}
app.MapPost("/beauty-router/webhooks", async (HttpRequest request) =>{ using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer); var rawBody = buffer.ToArray();
if (!IsValidSignature(request.Headers["Beauty-Router-Signature"].ToString(), rawBody)) { return Results.BadRequest(); }
// Skip the event when you already saved its id. Otherwise save it and handle it after you respond. return Results.Ok();});
app.Run();This example uses Spring Boot, and needs Java 17 or later:
import java.nio.charset.StandardCharsets;import java.security.GeneralSecurityException;import java.security.MessageDigest;import java.time.Instant;import java.util.ArrayList;import java.util.HexFormat;import java.util.List;
import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestHeader;import org.springframework.web.bind.annotation.RestController;
@RestControllerpublic class BeautyRouterWebhookController { private static final long MAX_SKEW_SECONDS = 5 * 60;
@Value("${beauty-router.webhook-secret}") private String webhookSecret;
@PostMapping("/beauty-router/webhooks") public ResponseEntity<Void> receive( @RequestHeader(name = "Beauty-Router-Signature", defaultValue = "") String header, @RequestBody byte[] rawBody) throws GeneralSecurityException { if (!isValidSignature(header, rawBody)) { return ResponseEntity.badRequest().build(); }
// Skip the event when you already saved its id. Otherwise save it and handle it after you respond. return ResponseEntity.ok().build(); }
private boolean isValidSignature(String header, byte[] rawBody) throws GeneralSecurityException { String timestamp = null; List<String> signatures = new ArrayList<>();
for (String part : header.split(",")) { String[] pair = part.trim().split("=", 2); if (pair.length != 2) { continue; } if (pair[0].equals("t")) { timestamp = pair[1]; } else if (pair[0].equals("v1")) { signatures.add(pair[1]); } }
if (timestamp == null || !timestamp.matches("\\d{1,18}") || Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > MAX_SKEW_SECONDS) { return false; }
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.UTF_8);
for (String signature : signatures) { if (MessageDigest.isEqual(expected, signature.getBytes(StandardCharsets.UTF_8))) { return true; } }
return false; }}This example uses net/http, and needs Go 1.18 or later:
package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "os" "strconv" "strings" "time")
const maxSkew = 5 * time.Minute
var webhookSecret = []byte(os.Getenv("BEAUTY_ROUTER_WEBHOOK_SECRET"))
func isValidSignature(header string, rawBody []byte) bool { var timestamp string var signatures []string
for _, part := range strings.Split(header, ",") { key, value, found := strings.Cut(strings.TrimSpace(part), "=") if !found { continue } switch key { case "t": timestamp = value case "v1": signatures = append(signatures, value) } }
seconds, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } skew := time.Since(time.Unix(seconds, 0)) if skew > maxSkew || skew < -maxSkew { return false }
mac := hmac.New(sha256.New, webhookSecret) mac.Write([]byte(timestamp + ".")) mac.Write(rawBody) expected := mac.Sum(nil)
for _, signature := range signatures { received, err := hex.DecodeString(signature) if err == nil && hmac.Equal(received, expected) { return true } }
return false}
func handleWebhook(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(r.Body) if err != nil || !isValidSignature(r.Header.Get("Beauty-Router-Signature"), rawBody) { w.WriteHeader(http.StatusBadRequest) return }
var event map[string]any if err := json.Unmarshal(rawBody, &event); err != nil { w.WriteHeader(http.StatusBadRequest) return }
// Skip the event when you already saved its id. Otherwise save it and handle it after you respond. w.WriteHeader(http.StatusOK)}
func main() { http.HandleFunc("/beauty-router/webhooks", handleWebhook) log.Fatal(http.ListenAndServe(":3000", nil))}Respond and retry
Section titled “Respond and retry”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.
