Appearance
Webhooks
Subscribe a URL and Tightly posts to it when something happens. Every delivery is signed, retried on failure, and recorded.
Delivery is at least once. A receiver that answered too slowly, or not at all, is sent the same delivery again under the same X-Tightly-Delivery-Id, so that header is the idempotency key your receiver keys on. Build for a repeat and a repeat costs nothing.
The events
| Event | Sent when | The body is |
|---|---|---|
order.created | An order is created, through the API, the Orders page or an import | The order |
order.updated | An order's dates, reference, warehouse or lines change, or it is allocated | The order |
order.fulfilled | Every unit on the order has shipped and the ledger has booked them | The order |
order.cancelled | An order is cancelled | The order |
return.created | A return is promised back, by a person, the API, an import or a store's refund | The return |
return.received | The dock records what arrived | The return |
receipt.recorded | A delivery against a purchase order is recorded and moves stock | The delivery |
movement.recorded | One row enters the stock ledger | The movement |
exception.opened | The queue has something a person has to decide | The queue row |
exception.resolved | Somebody resolves a queue row | The queue row |
report.delivered | A scheduled trade report went out | The delivery receipt |
report.failed | A scheduled trade report did not go out | The delivery receipt |
cycle.step_closed | A planning cycle's step closes | The step |
cycle.signed | A planning cycle is signed | The cycle |
new_purchase_order | A purchase order is created | The purchase order |
purchase_order_updated | A purchase order changes | The purchase order |
The last two predate the rest and keep their underscored spelling for good, so a receiver matching on those strings is not broken by the fourteen named after them in resource.verb.
One delivery per thing that happened, never per attempt: a receipt recorded twice with the same figures moves nothing the second time and sends nothing the second time. What you do still have to expect is the same delivery arriving twice from a retry you answered too slowly, which is what X-Tightly-Delivery-Id is for.
Subscribing
Settings, then Developer, then Webhooks, then Add. Give it a URL and one event. One subscription is one event, so subscribe the same address several times to hear several, and read X-Tightly-Event to tell them apart.
The signing secret is shown once, at that moment, in full. Copy it now: Tightly cannot show it again. A row menu offers New secret when you need to rotate, which replaces the secret rather than adding a second one.
Webhooks are sold with the public API, which is sold with Essentials. Below it the Webhooks room is not there, with one exception that is deliberate: an organisation already running a live subscription keeps the room whatever its plan says, so a plan is never the reason you cannot stop an address that has started leaking. Deactivate or delete your last subscription and the plan applies again.
What a delivery looks like
http
POST /hooks/tightly HTTP/1.1
Content-Type: application/json
User-Agent: Tightly-Webhook/1.0
X-Tightly-Event: new_purchase_order
X-Tightly-Delivery-Id: 4d0c2b1a9e8f7c6b5a4d3e2f1c0b9a88
X-Tightly-Signature: t=1793825941,v1=8b1a...c3f9| Header | What it is |
|---|---|
X-Tightly-Event | Which event this is, one of the sixteen above. |
X-Tightly-Delivery-Id | This delivery. Stable across retries, so it is your idempotency key. |
X-Tightly-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See below. |
The body is the record itself, in the shape its own read answers with and with no response envelope around it: the record, not {"data": {...}}. An update carries the record as it now stands rather than a list of what changed. The example below is a purchase order, the shape Get purchase order serves, which is what the two purchase-order events post.
json
{
"currency": "USD",
"expected_delivery_date": "2026-10-02",
"external_id": "PO-9014",
"id": "9014",
"line_items": [
{
"delivered_quantity": 0,
"quantity": 240,
"sku": "TB-CREW-BLK-M",
"total_cost": 2976,
"unit_cost": 12.4,
"variant_id": "44100920011"
}
],
"location_id": "loc_0004",
"name": "PO-9014 Porto Knits",
"order_date": "2026-09-04",
"order_type": "purchase",
"status": "issued",
"supplier_id": "sup_0031",
"supplier_name": "Porto Knits",
"total_cost": 148200
}Fields are added to it the way they are added to the operation, which the changelog records. A receiver that reads the fields it knows and ignores the rest never has to be redeployed for an addition.
Verifying the signature
The signed string is the timestamp, a dot, and the exact request body:
"<t>.<raw body bytes>"The digest is HMAC-SHA256 of that string with your signing secret, hex encoded.
Verify in five steps: split the header, refuse a t outside your tolerance, recompute the digest over f"{t}.{raw_body}", compare in constant time, then answer.
python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
"""True only for a delivery this secret signed, recently."""
parts = dict(part.split("=", 1) for part in header.split(","))
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
return False
# The timestamp is inside the signed string, so a captured delivery cannot be replayed
# tomorrow -- but only if you actually check it.
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)js
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map(part => part.split('=')))
const { t: timestamp, v1: signature } = parts
if (!timestamp || !signature) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(signature, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}Verify against the raw bytes, before any JSON parsing. A body that is parsed and re-serialised is a different byte string, and its digest will not match. In Express that means express.raw({ type: 'application/json' }) on the route, not express.json().
There is one scheme, v1, and no version negotiation.
A receiver, whole
js
import express from 'express'
import { verify } from './verify.js'
const app = express()
const seen = new Set() // In production this is a table, not a Set.
app.post('/hooks/tightly', express.raw({ type: '*/*' }), (request, response) => {
const signature = request.get('X-Tightly-Signature')
if (!signature || !verify(request.body, signature, process.env.TIGHTLY_WEBHOOK_SECRET)) {
return response.sendStatus(401)
}
const delivery = request.get('X-Tightly-Delivery-Id')
if (seen.has(delivery)) return response.sendStatus(204) // A retry of work already done.
seen.add(delivery)
// Answer first, work afterwards: anything slower than 30 seconds is a failure to us.
response.sendStatus(204)
queue.push({ event: request.get('X-Tightly-Event'), body: JSON.parse(request.body) })
})What counts as delivered, and what is retried
Any 2xx is delivered. 204 No Content is the right answer for an endpoint that consumes and returns nothing, and it counts.
Anything else, and any transport failure, is retried: after 1 minute, then 10 minutes, then 1 hour. Three retries, four attempts, spanning about seventy minutes. The first catches a restart, the second a deploy, the third an outage somebody had to be paged for.
A receiver slower than 30 seconds is treated as down.
Deliveries can repeat. A retry of a delivery your receiver did in fact process (but answered too slowly, or failed to answer at all) arrives with the same X-Tightly-Delivery-Id. Key your idempotency on that header and a repeat is free.
Reading what happened
The webhook's row menu offers Deliveries: when, which event, the status, and how many attempts. The row itself carries a Delivered column that reads Today, Failed Sep 2, 2026, Not sent yet, or No secret yet.
Subscribing by API
webhooks:write is a later scope, so for now a webhook is created on the Developer page or on a user session:
bash
curl -X POST "https://api.app.tightly.io/api/v1/webhooks" \
-H "Authorization: Bearer <a user token>" \
-H "X-Organization-ID: <your organisation>" \
-H "Content-Type: application/json" \
-d '{"hook_url": "https://example.com/hooks/tightly", "type": "new_purchase_order"}'The response carries the signing secret once, in the same shape the page shows it.