# 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. ---