# The Flower Factory API - Full Context
> Generated by docmd
---
## [Authentication](https://docs.theflowerfactory.ie/authentication/) (Authentication)
---
title: "Authentication"
description: "Get an access token with the OAuth 2.0 client credentials flow."
---
# Authentication
Every request needs an OAuth 2.0 access token, sent in the `Authorization` header:
```bash
curl "https://api.sandbox.theflowerfactory.ie/partner/v1/orders/WEB-100234" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
## Your credentials
When we set you up, we give you a **client ID** and a **client secret**. They identify your application and the account your orders belong to: you only ever see your own orders.
If your service runs on Kubernetes (such as AKS or EKS), or anywhere else that issues OpenID Connect tokens to workloads, you can [authenticate without a secret](#authentication-authenticating-without-a-secret) instead.
## Getting a token
Your service gets a token from Microsoft Entra ID, then sends it with each request until it's close to expiring:
```mermaid
sequenceDiagram
participant S as Your service
participant E as Microsoft Entra ID
participant A as The Flower Factory API
S->>E: POST /oauth2/v2.0/token
client ID, client secret, scope
E-->>S: access token, lasts about an hour
loop Until the token is close to expiring
S->>A: Request with Authorization: Bearer token
A-->>S: Response
end
```
Get the token with the client credentials flow:
```bash
curl -X POST "https://login.microsoftonline.com/b66ad013-3458-4610-a433-7bdd1264b5b4/oauth2/v2.0/token" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "scope=api://24579c38-caed-4940-a35f-8fc7763b718c/.default"
```
The response has the token in `access_token`, and how many seconds it lasts in `expires_in` (about an hour). Reuse the token until it's close to expiring, then get a new one: don't get a new token for every request.
Most OAuth 2.0 libraries handle this for you. In .NET, for example, use MSAL's `ConfidentialClientApplicationBuilder` (Microsoft.Identity.Client) with the same tenant and scope: it caches the token and gets a new one when it expires.
## When a request is refused
- **401 Unauthorized**: the token is missing, invalid or expired. Get a new token and try again.
- **403 Forbidden**: the token is valid, but not for an application we've set up as a partner. Check you're using the credentials we gave you for this environment.
::: callout warning "Keep your client secret safe"
Treat it like a password. Keep it on your server, never in a browser, mobile app or source control. If you think it has leaked, tell us and we'll replace it.
:::
## Authenticating without a secret
With workload identity federation, your service proves who it is with a token its own platform gives it, so there's no secret to store or rotate. Kubernetes gives each pod a signed token for its service account; Microsoft Entra ID checks that token against your cluster's published keys and, if it's from the service account we've set up, gives you an access token for this API. The access token is the same as one you'd get with a secret.
It works with AKS, EKS, GKE and any other platform whose OpenID Connect issuer is reachable from the internet.
```mermaid
sequenceDiagram
participant K as Your cluster
participant P as Your pod
participant E as Microsoft Entra ID
participant A as The Flower Factory API
K->>P: Service account token, in a file
P->>E: POST /oauth2/v2.0/token
client ID, scope, service account token as client_assertion
E->>K: Checks the token against your cluster's published keys
E-->>P: access token, lasts about an hour
P->>A: Request with Authorization: Bearer token
A-->>P: Response
```
### What to send us
- Your cluster's **OIDC issuer URL**:
- AKS: `az aks show --name CLUSTER --resource-group GROUP --query oidcIssuerProfile.issuerUrl --output tsv`
- EKS: `aws eks describe-cluster --name CLUSTER --query cluster.identity.oidc.issuer --output text`
- The **namespace** and **service account** your service runs as.
We send you a client ID. Each cluster and service account needs setting up, so tell us about each one you'll call from (up to 20).
### AKS
Turn on the cluster's OIDC issuer and workload identity, if they aren't already:
```bash
az aks update --name CLUSTER --resource-group GROUP --enable-oidc-issuer --enable-workload-identity
```
Annotate the service account with the client ID we gave you and our tenant ID, and label your pods:
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: tff-client
namespace: orders
annotations:
azure.workload.identity/client-id: YOUR_CLIENT_ID
azure.workload.identity/tenant-id: b66ad013-3458-4610-a433-7bdd1264b5b4
---
# In your Deployment's pod template:
metadata:
labels:
azure.workload.identity/use: "true"
spec:
serviceAccountName: tff-client
```
The tenant ID annotation matters: it's our tenant, not yours. Workload identity then gives your pods `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and `AZURE_FEDERATED_TOKEN_FILE`.
### EKS
Mount a service account token for Microsoft Entra ID, and point the same variables at it:
```yaml
spec:
serviceAccountName: tff-client
containers:
- name: app
env:
- name: AZURE_TENANT_ID
value: b66ad013-3458-4610-a433-7bdd1264b5b4
- name: AZURE_CLIENT_ID
value: YOUR_CLIENT_ID
- name: AZURE_FEDERATED_TOKEN_FILE
value: /var/run/secrets/tff/token
volumeMounts:
- name: tff-token
mountPath: /var/run/secrets/tff
readOnly: true
volumes:
- name: tff-token
projected:
sources:
- serviceAccountToken:
path: token
audience: api://AzureADTokenExchange
expirationSeconds: 3600
```
The audience must be `api://AzureADTokenExchange`. Kubernetes replaces the token before it expires, so read the file each time you get an access token rather than once at start-up.
### Getting a token
The Azure Identity libraries do this for you from those three variables. In .NET:
```csharp
using Azure.Core;
using Azure.Identity;
var credential = new WorkloadIdentityCredential();
var token = await credential.GetTokenAsync(
new TokenRequestContext(["api://24579c38-caed-4940-a35f-8fc7763b718c/.default"]));
// Send token.Token as the Bearer token. The credential caches it and gets a new one when it expires.
```
Python (`azure-identity`), JavaScript (`@azure/identity`), Java and Go have a `WorkloadIdentityCredential` too. Without a library, send the Kubernetes token as a client assertion instead of a secret:
```bash
curl -X POST "https://login.microsoftonline.com/b66ad013-3458-4610-a433-7bdd1264b5b4/oauth2/v2.0/token" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "scope=api://24579c38-caed-4940-a35f-8fc7763b718c/.default" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion@/var/run/secrets/tff/token"
```
If the exchange fails with `AADSTS70021` (no matching federated identity record), the token's issuer, service account or audience doesn't match what we set up: check the namespace and service account name, and the audience on EKS.
---
## [Environments](https://docs.theflowerfactory.ie/environments/) (Environments)
---
title: "Environments"
description: "Base URLs for the sandbox and production environments."
---
# Environments
There are two environments. Build and test against the sandbox, then switch to production when you go live.
| Environment | Base URL |
| --- | --- |
| Sandbox | `https://api.sandbox.theflowerfactory.ie` |
| Production | `https://api.theflowerfactory.ie` |
Every endpoint is under `/partner/v1`, for example `https://api.sandbox.theflowerfactory.ie/partner/v1/orders`.
We set you up in the sandbox first. When you're ready to go live, we set you up in production too.
## OpenAPI document
The [OpenAPI document](/assets/openapi/partner.json){data-spa-ignore} describes every endpoint, field and response. Use it to generate a client, or import it into a tool such as Postman.
---
## [Errors](https://docs.theflowerfactory.ie/errors/) (Errors)
---
title: "Errors"
description: "What error responses look like, and what to do about them."
---
# Errors
Errors are [problem details](https://www.rfc-editor.org/rfc/rfc9457), sent as `application/problem+json`. The `title` says what went wrong, and the `detail` usually says what to do about it:
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Order already exists",
"status": 409,
"detail": "You've already sent an order with the reference WEB-100234. Get it from the Location URL to see it."
}
```
## Invalid requests
A request that isn't valid gets **400 Bad Request**, with each problem listed under `errors`, keyed by the field's path:
```json
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Delivery.Date": ["The delivery date can't be in the past."],
"Items[0].Quantity": ["The field Quantity must be between 1 and 100."]
}
}
```
The paths start with a capital letter, but they name the same fields as the request: `Items[0].Quantity` is `items[0].quantity`.
Fix the request before you send it again: the same request gets the same error.
## Status codes
| Status | Meaning | What to do |
| --- | --- | --- |
| 400 | The request isn't valid. | Fix the fields listed under `errors`. |
| 401 | The access token is missing, invalid or expired. | Get a new token. See [Authentication](/authentication/). |
| 403 | The token isn't for a partner application we've set up. | Check you're using the right credentials for the environment. |
| 404 | There's no order with that reference among your orders. | Check the reference. |
| 409 | Creating: you've already sent an order with that reference. Changing: we've started the order, or it changed while you were updating it. | Creating: the order exists, so get it by its reference. Changing: the `detail` says which; contact us, or get the order and send the change again. |
| 5xx | Something went wrong on our side. | Try again after a short wait. |
## Retrying
It's safe to retry creating an order. Each order has a reference of your own, and we never create two orders with the same reference: if the first attempt got through, the retry gets **409** and you can get the order by its reference.
---
## [Introduction](https://docs.theflowerfactory.ie/)
---
title: "Introduction"
description: "Send orders to The Flower Factory and follow them through to delivery."
---
::: hero
# The Flower Factory API
Send orders to The Flower Factory and follow them through to delivery, with a JSON-over-HTTPS REST API.
::: button title:"Quickstart" url:"/quickstart/" icon:"zap"
::: button title:"API reference" url:"/reference/orders/" icon:"braces"
::: /hero
## Where to start
1. Follow the [Quickstart](/quickstart/) to send a test order to the sandbox.
2. Read [Environments](/environments/) and [Authentication](/authentication/) to set up your own service.
3. Read [Errors](/errors/) to see what to do when a request fails.
4. Use the [API reference](/reference/orders/) for every endpoint, field and response.
## Using an AI assistant
These docs are also published for AI coding assistants: [llms.txt](/llms.txt){data-spa-ignore} lists every page, and [llms-full.txt](/llms-full.txt){data-spa-ignore} has all of them in one file. Give either to your assistant, or use **Copy Context** at the top of a page to copy just that page.
---
## [Quickstart](https://docs.theflowerfactory.ie/quickstart/) (Quickstart)
---
title: "Quickstart"
description: "Send your first order to the sandbox and follow it, in five steps."
---
# Quickstart
Send your first order to the sandbox and get it back, in five steps. You need `curl`, and `jq` to read the token from the response.
::: steps
1. **Get your sandbox credentials**
Ask us to set you up in the sandbox. We give you a **client ID** and a **client secret**. If your service runs on Kubernetes, you can [use workload identity instead of a secret](/authentication/#authentication-authenticating-without-a-secret).
```bash
export CLIENT_ID="your client ID"
export CLIENT_SECRET="your client secret"
```
2. **Get an access token**
```bash
export TOKEN=$(curl -s -X POST "https://login.microsoftonline.com/b66ad013-3458-4610-a433-7bdd1264b5b4/oauth2/v2.0/token" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=api://24579c38-caed-4940-a35f-8fc7763b718c/.default" | jq -r .access_token)
```
It lasts about an hour. See [Authentication](/authentication/) for reusing it, and for what to do in your own code.
3. **Create an order**
Change the delivery date to today or later, then send the order:
```bash
curl -s -X POST "https://api.sandbox.theflowerfactory.ie/partner/v1/orders" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reference": "QUICKSTART-1",
"purchaser": { "name": "Siobhán Murphy", "email": "siobhan@example.com" },
"recipient": {
"name": "Mary Murphy",
"telephone": "+353 87 123 4567",
"address": { "line1": "12 Main Street", "city": "Dublin 6", "postcode": "D06 A1B2", "country": "Ireland" }
},
"delivery": { "date": "2026-11-14" },
"message": "Happy birthday",
"items": [{ "sku": "BQT-ROSE-12", "name": "A dozen red roses", "quantity": 1, "unitPrice": 49.99 }]
}'
```
You get **201 Created** and the order, with `"status": "Received"`. A **400** lists what to fix under `errors`; a **409** means you've already used that reference, so pick another.
4. **Get the order back**
```bash
curl -s "https://api.sandbox.theflowerfactory.ie/partner/v1/orders/QUICKSTART-1" \
-H "Authorization: Bearer $TOKEN"
```
You find orders by your own reference. Until we start making it you can also change it, by sending the whole order again with `PUT` to the same URL.
5. **Follow it to delivery**
Get the order again to see its [status](/reference/orders/#orders-statuses) and, once it's dispatched, its tracking numbers in `shipments`. Or have us tell you when it changes, with [webhooks](/webhooks/).
:::
## Next
- [Orders](/reference/orders/): every field and response.
- [Errors](/errors/): what to do when a request fails, and retrying safely.
- [Environments](/environments/): moving from the sandbox to production.
---
## [Orders](https://docs.theflowerfactory.ie/reference/orders/) (Reference)
---
title: "Orders"
description: "Create, change, get and list your orders."
---
# Orders
Send us orders, change them, and follow them through to delivery. You only ever see your own orders, and you find each one by your own reference.
The endpoints are also in the [OpenAPI document](/assets/openapi/partner.json){data-spa-ignore}, for generating a client.
## References
Every order needs a reference of your own, such as your order number: letters, digits, `.`, `_` and `-`, up to 64 characters. Use each reference once. Sending a reference again gets **409**, so it's safe to retry creating an order (see [Errors](/errors/)).
## Changing an order
You can change an order while its status is `Received`, before we start making it. Send the whole order again to `PUT /partner/v1/orders/{reference}`: it replaces the purchaser, recipient, delivery, message and items, so include every field, not only the ones that changed. The reference can't be changed.
Once we've started an order you get **409**. Contact us if it still needs to change.
## Statuses
| Status | Meaning |
| --- | --- |
| `Received` | We have the order and haven't started it. |
| `InProgress` | It's being made. |
| `Packed` | It's made and waiting for the courier. |
| `Dispatched` | It's with the courier. `shipments` has the tracking numbers. |
| `Delivered` | It's been delivered. |
| `DeliveryFailed` | The courier couldn't deliver it, and will usually try again. |
| `Returned` | It came back to us. |
| `OnHold` | We've paused it, and will be in touch. |
| `Cancelled` | It's been cancelled. |
We may add statuses later, so treat one you don't recognise as "in progress" rather than failing.
How an order usually moves between them:
```mermaid
flowchart TD
subgraph before [Before dispatch]
Received --> InProgress --> Packed
end
before --> Dispatched
Dispatched --> Delivered
Dispatched --> DeliveryFailed
DeliveryFailed -->|tried again| Dispatched
DeliveryFailed --> Returned
Dispatched --> Returned
before --> OnHold
OnHold -->|resumed| before
before --> Cancelled
OnHold --> Cancelled
```
An order on hold goes back to where it was when we resume it. You see the status an order has when you look, so it can seem to skip statuses: for example, straight from `Received` to `Dispatched`.
## Endpoints
```openapi
assets/openapi/tags/orders.json
```
---
## [Webhooks endpoints](https://docs.theflowerfactory.ie/reference/webhooks/) (Reference)
---
title: "Webhooks endpoints"
description: "Manage your webhooks, see their deliveries and send events again."
---
# Webhooks endpoints
Manage your webhooks, see how sending each event went, and send events again. What we send to a webhook, and how to check its signature, is in [Webhooks](/webhooks/).
These endpoints only work if we've turned on managing your own webhooks for you; otherwise they answer **403**, and we manage your webhooks for you.
```openapi
assets/openapi/tags/webhooks.json
```
---
## [Webhooks](https://docs.theflowerfactory.ie/webhooks/) (Webhooks)
---
title: "Webhooks"
description: "Get events about your orders sent to your own endpoint."
---
# Webhooks
Instead of checking your orders for changes, you can have us send events to an HTTPS endpoint of yours. We notice changes within about a minute.
## Events
| Event | When |
| --- | --- |
| `order.status_changed` | An order's [status](/reference/orders/#orders-statuses) changes. [Payload](#webhooks-payloads-orderstatuschanged) |
| `order.shipment_added` | An order gets a new tracking number with the courier. [Payload](#webhooks-payloads-ordershipmentadded) |
A webhook can get either event or both.
## Setting up a webhook
We can set up your webhooks for you: tell us the URL and the events you want, and we'll give you its signing secret. If we've turned it on for you, you can also manage them yourself with the [webhooks endpoints](/reference/webhooks/). You can have up to five webhooks.
The URL must be HTTPS and reachable from the internet.
## Payloads
Each event is a `POST` with a JSON body. Every event has the same shape: what happened in `type`, and in `data` the order as it was when we noticed, plus the field for that event type.
| Field | Type | |
| --- | --- | --- |
| `id` | string | The event's ID, such as `evt_3cd3371cda5c4284bb7aae0a66fa4c98`. Also sent as the `webhook-id` header. |
| `type` | string | `order.status_changed` or `order.shipment_added`. |
| `occurredAt` | string (date-time, UTC) | When we noticed the change, within about a minute of it happening. |
| `data.order` | [Order](#webhooks-payloads-the-order) | The order as it was at `occurredAt`. |
| `data.previousStatus` | string or null | For `order.status_changed`: the order's status before. Null for other events. |
| `data.shipment` | [Shipment](#webhooks-payloads-the-order) or null | For `order.shipment_added`: the new consignment. Null for other events. |
Every field is always present: one that doesn't apply is `null`, not left out.
### order.status_changed
Sent when an order's [status](/reference/orders/#orders-statuses) changes. `data.order.status` is the new status and `data.previousStatus` the old one.
```json
{
"id": "evt_3cd3371cda5c4284bb7aae0a66fa4c98",
"type": "order.status_changed",
"occurredAt": "2026-11-02T10:00:00Z",
"data": {
"order": {
"reference": "WEB-100234",
"status": "Dispatched",
"receivedAt": "2026-11-01T14:00:00Z",
"purchaser": { "name": "Siobhán Murphy", "email": "siobhan@example.com" },
"recipient": {
"name": "Mary Murphy",
"telephone": "+353 87 123 4567",
"address": { "line1": "12 Main Street", "line2": null, "city": "Dublin 6", "county": null, "postcode": "D06 A1B2", "country": "Ireland" }
},
"delivery": { "date": "2026-11-14", "earliestTime": "09:00", "latestTime": "13:00" },
"message": "Happy birthday",
"items": [{ "sku": "BQT-ROSE-12", "name": "A dozen red roses", "quantity": 2, "unitPrice": 49.9 }],
"shipments": [{ "carrier": "DPD", "trackingNumber": "15502000123456" }]
},
"previousStatus": "Received",
"shipment": null
}
}
```
You get the status the order has when we look, so it can skip statuses: an order that went from `Received` through `InProgress` and `Packed` to `Dispatched` between two checks arrives as one event from `Received` to `Dispatched`, as above. Don't expect every status in turn.
### order.shipment_added
Sent when an order gets a tracking number it didn't have, once for each new one. `data.shipment` is the new consignment, and it's also in `data.order.shipments`.
```json
{
"id": "evt_6d56326f21b0494eaeaff963a55571e9",
"type": "order.shipment_added",
"occurredAt": "2026-11-02T10:00:00Z",
"data": {
"order": {
"reference": "WEB-100234",
"status": "Dispatched",
"receivedAt": "2026-11-01T14:00:00Z",
"purchaser": { "name": "Siobhán Murphy", "email": "siobhan@example.com" },
"recipient": {
"name": "Mary Murphy",
"telephone": "+353 87 123 4567",
"address": { "line1": "12 Main Street", "line2": null, "city": "Dublin 6", "county": null, "postcode": "D06 A1B2", "country": "Ireland" }
},
"delivery": { "date": "2026-11-14", "earliestTime": "09:00", "latestTime": "13:00" },
"message": "Happy birthday",
"items": [{ "sku": "BQT-ROSE-12", "name": "A dozen red roses", "quantity": 2, "unitPrice": 49.9 }],
"shipments": [{ "carrier": "DPD", "trackingNumber": "15502000123456" }]
},
"previousStatus": null,
"shipment": { "carrier": "DPD", "trackingNumber": "15502000123456" }
}
}
```
An order usually gets its tracking number as it's dispatched, so expect this event alongside an `order.status_changed` to `Dispatched`, in either order.
### The order
`data.order` is the same as [getting the order](/reference/orders/):
| Field | Type | |
| --- | --- | --- |
| `reference` | string | Your reference for the order. |
| `status` | string | Where the order has got to: one of the [statuses](/reference/orders/#orders-statuses). |
| `receivedAt` | string (date-time, UTC) or null | When we received the order. |
| `purchaser.name`, `purchaser.email` | string | The person who placed the order. |
| `recipient.name`, `recipient.telephone` | string | The person the order is for. |
| `recipient.address.line1`, `.country` | string | The first line of the delivery address, and its country. |
| `recipient.address.line2`, `.city`, `.county`, `.postcode` | string or null | The rest of the address, where you gave it. |
| `delivery.date` | string (date) | The delivery date, such as `2026-11-14`. |
| `delivery.earliestTime`, `delivery.latestTime` | string or null | The delivery window in 24-hour Irish time, such as `09:00`, where you gave one. |
| `message` | string or null | The card message. |
| `items[]` | array | What's in the order: `sku`, `name`, `quantity` (a number) and `unitPrice` (a number, in euro). |
| `shipments[]` | array | The order's consignments with the courier, empty until it's dispatched: `carrier` (such as `DPD`, or null if we don't know it) and `trackingNumber`. |
We may add fields to the body later, so ignore any you don't recognise rather than refusing the event.
## Headers
With each event come three headers, as in the [Standard Webhooks](https://www.standardwebhooks.com) specification:
| Header | |
| --- | --- |
| `webhook-id` | The event's ID, the same as `id` in the body. |
| `webhook-timestamp` | When we sent it, in seconds since 1970 (UTC). |
| `webhook-signature` | `v1,` and the signature. |
## Answering
Answer with any 2xx status within 10 seconds. Do the work afterwards if it takes longer: save the event and answer straight away.
```mermaid
sequenceDiagram
participant T as The Flower Factory
participant Y as Your endpoint
Note over T: An order changes, and we notice within about a minute
T->>Y: POST the event, signed
alt 2xx within 10 seconds
Y-->>T: Delivered
else Any other status, a redirect, or no answer
Y-->>T: Failed
Note over T: Try again after 1 min, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h
T->>Y: POST the same event, with the same webhook-id
end
Note over T,Y: Every delivery failing for a day turns the webhook off
```
Anything else, including a redirect or a timeout, is a failed delivery. We try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, then stop. If every delivery to a webhook has failed for a day, we turn the webhook off. Turn it back on once your endpoint is fixed, then send the missed events again from its [deliveries](/reference/webhooks/).
## Checking the signature
Check every event's signature before you trust it, so you know it came from us and wasn't changed. The signature is an HMAC-SHA256 of the `webhook-id`, the `webhook-timestamp` and the raw body, joined with `.`. The key is your secret without its `whsec_` prefix, base64-decoded. Also refuse events with a timestamp more than five minutes from now, so an old event can't be sent to you again.
The [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks) do all of this for you. Without one, in Node.js:
```js
import crypto from "node:crypto";
// rawBody is the request body exactly as received, before parsing it.
function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
return headers["webhook-signature"].split(" ").some((signature) => {
const [version, value] = signature.split(",");
const given = Buffer.from(value ?? "", "base64");
return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
```
Or in C#:
```csharp
using System.Security.Cryptography;
using System.Text;
static bool Verify(string secret, string id, string timestamp, string signatureHeader, string rawBody)
{
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300) return false;
var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret);
var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{rawBody}"));
return signatureHeader.Split(' ').Any(signature =>
signature.StartsWith("v1,") &&
CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(signature[3..]), expected));
}
```
## Duplicates and order
An event can arrive more than once, for example when your answer to the first delivery was lost, and events can arrive out of order. So:
- Use `webhook-id` to skip events you've already handled. Every delivery of an event, including one you ask us to send again, has the same ID.
- Don't rely on the order events arrive in. Compare `occurredAt`, or get the order when you need to be sure of its current state.
---