Webhooks
Send order events to your other systems: the events and their bodies, the headers, verifying the signature (Node and Python), retries, Resend and test events.
What webhooks do
A webhook tells another system (your POS, a production board, a spreadsheet, Zapier) about an order as it happens: ThreadReady sends an HTTPS POST with a JSON body to an address you choose. Owners and admins set them up in Settings → Webhooks (Add Webhook: a name, the target URL and the events to send). Each webhook gets its own signing secret, so your system can check that a message really came from ThreadReady. Webhooks aren't available in the demo.
To read orders or move an order's status from your system, use the Order API instead (Settings → API Keys, see Managing Orders).
Events
order.created(Order Created in Settings): a customer places an order at the kiosk, or staff import orders from a CSV file. Once per order.order.updated(Order Updated): staff change the customer's name, email or phone, the staff note or the POS reference, or correct the order's personalization (sent when the correction applies, or when the customer approves the updated proof).changedFieldssays what changed.order.status.changed(Order Status Changed): an order's status changes, from the order page, the scan page or the API.
A status change doesn't also send order.updated.
Status changes
Order Status Changed is sent as order.status.changed, with the new status in status and the one before it in previousStatus.
Each status is an object with an id, a name and a color. Match on id, not name: names are what staff see and can change (in October 2026 "Ready for Production" became "In production"), ids don't. The ids are unpaid (awaiting payment; older orders may be submitted, and a draft is saved), paid, ready_for_production (in production), ready_for_pickup, completed (picked up) and cancelled. previousStatus is the status the order had just before.
What a delivery looks like
Every body has the same envelope:
| Field | What it is |
|---|---|
event | The event id, also in the x-threadready-event header. |
source | Always threadready. |
tenantId | Your workspace's id. |
tenantSlug | Your workspace's address (changes if you rename the workspace). |
order | The order as it is after the event: id, orderNumber, status, customer (name, email, phone), template (the design's id), comment (the customer's kiosk comment), posReference, createdAt, updatedAt. |
Fields can be null. New fields may be added, so ignore ones you don't know.
Example: order.created
{
"event": "order.created",
"source": "threadready",
"tenantId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"tenantSlug": "your-shop",
"order": {
"id": "0f8c2b1e-4d3a-4c6b-9e2f-1a2b3c4d5e6f",
"orderNumber": "TR-20261028-1A2B3C4D",
"status": "unpaid",
"customer": {
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+1 555 0100"
},
"template": "7d9e1f20-3b4c-4d5e-8f6a-7b8c9d0e1f2a",
"comment": "Please center the name",
"posReference": null,
"createdAt": "2026-10-28T15:04:05.000Z",
"updatedAt": "2026-10-28T15:04:05.000Z"
}
}
Example: order.updated
changedFields can contain customerName, customerEmail, customerPhone, staffNote, posReference, and personalization with pricingSnapshot for a correction. The staff note itself isn't sent.
{
"event": "order.updated",
"source": "threadready",
"tenantId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"tenantSlug": "your-shop",
"order": {
"id": "0f8c2b1e-4d3a-4c6b-9e2f-1a2b3c4d5e6f",
"orderNumber": "TR-20261028-1A2B3C4D",
"status": "unpaid",
"customer": {
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+1 555 0100"
},
"template": "7d9e1f20-3b4c-4d5e-8f6a-7b8c9d0e1f2a",
"comment": "Please center the name",
"posReference": "POS-4821",
"createdAt": "2026-10-28T15:04:05.000Z",
"updatedAt": "2026-10-28T15:20:00.000Z"
},
"changedFields": [
"posReference",
"staffNote"
]
}
Example: order.status.changed (Order Status Changed)
{
"event": "order.status.changed",
"source": "threadready",
"tenantId": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"tenantSlug": "your-shop",
"order": {
"id": "0f8c2b1e-4d3a-4c6b-9e2f-1a2b3c4d5e6f",
"orderNumber": "TR-20261028-1A2B3C4D",
"status": "ready_for_pickup",
"customer": {
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+1 555 0100"
},
"template": "7d9e1f20-3b4c-4d5e-8f6a-7b8c9d0e1f2a",
"comment": "Please center the name",
"posReference": null,
"createdAt": "2026-10-28T15:04:05.000Z",
"updatedAt": "2026-10-28T15:04:05.000Z"
},
"status": {
"id": "ready_for_pickup",
"name": "Ready for pickup",
"color": "#0891b2"
},
"previousStatus": {
"id": "ready_for_production",
"name": "In production",
"color": "#8b5cf6"
}
}
The same examples are in Settings → Webhooks (open a webhook, then Example payload under each event) and in the webhooks section of the API reference (/api/v1/openapi.json).
Headers
| Header | What it is |
|---|---|
content-type | application/json |
user-agent | threadready-webhook/1.0 |
x-threadready-delivery | This delivery's id. The same on every retry and Resend of one event to one webhook. |
x-threadready-event | The event id, the same as event in the body. |
x-threadready-timestamp | When this attempt was sent, in Unix seconds. New on every attempt. |
x-threadready-signature | The signature: see below. |
Verify the signature
x-threadready-signature is the HMAC-SHA256, written as lowercase hex, of the timestamp, a full stop, and the raw request body:
HMAC-SHA256(signing secret, x-threadready-timestamp + "." + raw body)
To check a delivery:
- Read the raw body, the exact bytes you received. Don't parse the JSON and turn it back into text first: the result can differ (spacing, key order, characters like é), and the signature won't match.
- Refuse the request if
x-threadready-timestampis more than 5 minutes from your clock. Someone who copied an old request can't replay it, because changing the timestamp breaks the signature. (Keep your server's clock right.) - Compute the HMAC with your webhook's signing secret and compare it with the header using a constant-time comparison, never
==. - Only then parse the JSON and act on it.
Node.js (Express):
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.THREADREADY_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 300
const app = express()
// express.raw keeps the body as the exact bytes received (a Buffer).
app.post('/threadready-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('x-threadready-timestamp') ?? ''
const signature = req.get('x-threadready-signature') ?? ''
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return res.status(400).send('Timestamp too old or missing')
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest('hex')
const received = Buffer.from(signature)
const wanted = Buffer.from(expected)
const valid = received.length === wanted.length && crypto.timingSafeEqual(received, wanted)
if (!valid) return res.status(401).send('Bad signature')
const deliveryId = req.get('x-threadready-delivery')
// If you have already handled deliveryId (a retry or a Resend), answer 200 and stop here.
const event = JSON.parse(req.body.toString('utf8'))
// Save the event or queue the work, then answer straight away.
res.sendStatus(200)
})
Python (Flask):
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["THREADREADY_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
app = Flask(__name__)
@app.post("/threadready-webhook")
def threadready_webhook():
timestamp = request.headers.get("x-threadready-timestamp", "")
signature = request.headers.get("x-threadready-signature", "")
raw_body = request.get_data() # the exact bytes received
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
abort(400)
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
delivery_id = request.headers["x-threadready-delivery"]
# If you have already handled delivery_id (a retry or a Resend), return 200 and stop here.
event = json.loads(raw_body)
# Save the event or queue the work, then answer straight away.
return "", 200
If you change the webhook's URL, its secret stays the same. The secret is shown in Settings → Webhooks (open the webhook, then the eye button); keep it out of your code and logs.
Answering a delivery
Answer with any 2xx status within 10 seconds. Anything else counts as a failure: another status, no answer in time, a redirect (redirects aren't followed) or an address that can't be reached. If your work takes longer, save the event, answer 200 and do the work afterwards.
ThreadReady sends webhooks after it has finished the order action, so a slow or broken receiver never holds up the customer at the kiosk or staff at the counter.
Retries and Resend
- First send: up to three tries, a fraction of a second apart.
- Then in the background: if those fail, ThreadReady tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (about 9 hours in all), one try each time.
- Then it stops and marks the delivery Failed.
While this is going on, the delivery is listed under Failed deliveries on Settings → Webhooks with its last error, the time of the next try (Retrying), and a Resend button for owners and admins. It stays there for 7 days. Resend sends the original body again, to the webhook's current address with its current secret, and stops the background tries if it gets through. A delivery that got through on a retry shows Delivered on retry.
Retries stop (and the delivery is marked failed with the reason) when the webhook is turned off or deleted, or the workspace is locked for billing. A turned-off webhook has to be turned back on before you can Resend, and a deleted one can't be resent.
Ignore repeats by delivery id
Every retry and every Resend of one event carries the same x-threadready-delivery id, and your system may receive one more than once (for example when it handled a delivery but answered too slowly). Store the ids you have handled for at least 7 days and ignore a delivery whose id you already have. Two different events (two status changes, say) always have different ids.
Because a retry can arrive after a later event, don't assume deliveries arrive in order: compare order.updatedAt, or read the order through the API, before you overwrite newer data.
Where webhooks can go
- The URL must start with
https://and must not contain a username or password. - It must be a public address. Addresses on a private network, loopback (
localhost,127.0.0.1) and link-local addresses are refused. ThreadReady checks this when you save the webhook and again before every delivery, so a name that later points to a private address is refused too; that delivery fails at once and isn't retried. (A name that doesn't resolve at all is retried as usual.) - To try webhooks on your own computer, use a tunnel that gives you a public https address.
When nothing is sent
- While the workspace is locked for billing, no webhooks are sent and the order's audit trail notes "Webhooks skipped: workspace locked". Events from that time aren't sent later; once billing is back, new events are sent as usual.
- A webhook that is turned off receives nothing, and nothing is stored for it.
- The demo never sends webhooks.
Send test event
Open a webhook in Settings → Webhooks and press Send test event. ThreadReady sends the example body of the first event you picked, with three extra fields, "test": true, sentAt and webhookId, signed like a real delivery, and shows what your system answered. Each test has a new delivery id. A test isn't retried in the background and isn't listed under Failed deliveries. The order in a test is example data, not one of your orders, so make your system ignore deliveries with "test": true once you are done.

