Webhooks

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 changes. Payload
order.shipment_added An order gets a new tracking number with the courier. Payload

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. 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 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 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 changes. data.order.status is the new status and data.previousStatus the old one.

{
  "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.

{
  "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:

Field Type
reference string Your reference for the order.
status string Where the order has got to: one of the 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 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.

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.

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 do all of this for you. Without one, in Node.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#:

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.