# Webhooks

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

## 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](/support/#get-access) 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](/authentication/#app-token). 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

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.

## Events

Each event is a `POST` request with a JSON body:

```json
{
  "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](/product-matching/)         |
| `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](/orders/)                           |
| `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:

```json
{
  "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](/orders/).

## Verify the signature

Each request has a `Beauty-Router-Signature` header:

```http
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:

```http
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:

```js
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:

```python
import hashlib
import hmac
import json
import os
import 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
<?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:

```ruby
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) }
  end
end
```

This example uses ASP.NET Core:

```csharp
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:

```java
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;

@RestController
public 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:

```go
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

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.