Skip to content

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 ​

EventSent whenThe body is
order.createdAn order is created, through the API, the Orders page or an importThe order
order.updatedAn order's dates, reference, warehouse or lines change, or it is allocatedThe order
order.fulfilledEvery unit on the order has shipped and the ledger has booked themThe order
order.cancelledAn order is cancelledThe order
return.createdA return is promised back, by a person, the API, an import or a store's refundThe return
return.receivedThe dock records what arrivedThe return
receipt.recordedA delivery against a purchase order is recorded and moves stockThe delivery
movement.recordedOne row enters the stock ledgerThe movement
exception.openedThe queue has something a person has to decideThe queue row
exception.resolvedSomebody resolves a queue rowThe queue row
report.deliveredA scheduled trade report went outThe delivery receipt
report.failedA scheduled trade report did not go outThe delivery receipt
cycle.step_closedA planning cycle's step closesThe step
cycle.signedA planning cycle is signedThe cycle
new_purchase_orderA purchase order is createdThe purchase order
purchase_order_updatedA purchase order changesThe 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
HeaderWhat it is
X-Tightly-EventWhich event this is, one of the sixteen above.
X-Tightly-Delivery-IdThis delivery. Stable across retries, so it is your idempotency key.
X-Tightly-Signaturet=<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.

Tightly API, version 2026-11.