Webhooks
Receive change-feed events as signed HTTPS POSTs. Manage endpoints from Settings → Developer or through this API. Deliveries are at-least-once and unordered — dedupe on the event id; the payload is a pointer, so order does not matter.
Webhooks deliver the same events as the change feed, pushed instead of polled. Endpoints can be managed two ways, and they are the same endpoints either way: from Settings → Developer → Webhook endpoints in the dashboard, or through this API. The dashboard is the shorter path if you only need one endpoint and a delivery log; the API is what you want if endpoints are provisioned per customer or per environment.
POST /api/v1/webhook_endpoints
Content-Type: application/json
{
"url": "https://hooks.example.com/tovr",
"enabled_events": ["shipment.updated", "shipment.unshared"]
}The response contains secret — the only time it is ever returned. Store it before you close
the connection; it cannot be read back. Requires webhooks:write, and subscribing to shipment.*
additionally requires shipments:read: an endpoint delivers data, it cannot grant access to it.
Changing what an endpoint receives
PATCH the endpoint. url, description and enabled_events can all be changed in place, and
none of them re-issues the signing secret — your receiver keeps verifying with the secret it
already has.
PATCH /api/v1/webhook_endpoints/whe_…
Content-Type: application/json
{ "enabled_events": ["shipment.updated", "chain.awarded"] }enabled_events replaces the whole set rather than adding to it, and it may not be empty — an
endpoint subscribed to nothing would never be called, so send a DELETE if that is what you mean.
The same per-event scope rule as create applies: each event type requires the read scope for the
object it is about.
That rule is checked again at every delivery, against the scopes the creating credential holds at that moment. If a credential loses a read scope, or is deleted, deliveries of the event types that needed it stop: they are skipped, not queued, so granting the scope again does not replay them. A credential that is deactivated stops all of its endpoints' deliveries the same way. Endpoints created from the dashboard settings are not tied to a credential and are not affected.
Verifying a delivery
A delivery is a POST whose body is the same event the feed serves:
{
"object": "event",
"id": "aev_9c3f2b10-…",
"type": "shipment.updated",
"object_type": "shipment",
"object_id": "shp_1f2e…"
}object_type is a union — shipment | vehicle | chain | match | quote | outreach —
and which one you receive follows from the event types you subscribed to. Give any exhaustive
match on it a default arm that ignores the event rather than failing: the union grows, and a
receiver that throws on an unrecognised member turns a new feature into your outage. See
the change feed.
with these headers:
tovr-signature: t=1755950400,v1=8d3f…
tovr-event-id: aev_9c3f2b10-…
tovr-event-type: shipment.updated
tovr-webhook-id: whe_4b8c…
tovr-delivery-attempt: 1const [tp, ...vs] = req.headers['tovr-signature'].split(',');
const t = tp.slice(2);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) throw new Error('stale');
const expected = crypto.createHmac('sha256', process.env.TOVR_WEBHOOK_SECRET)
.update(`${t}.${rawBody}`) // ⚠️ RAW BODY — not JSON.stringify(req.body)
.digest('hex');
const ok = vs.some(v => crypto.timingSafeEqual(
Buffer.from(v.slice(3), 'hex'), Buffer.from(expected, 'hex')));Use the raw request body. Re-serializing req.body produces different bytes and the signature
will never match. With express.json() this is the default mistake — reach for
express.raw({ type: 'application/json' }) on the webhook route.
The timestamp is part of what is signed, not just a header, so a captured delivery cannot be
replayed with a fresh t. Reject anything outside 300 seconds. If verification fails on every
delivery, check your server's clock before checking your code.
Rotation
POST /v1/webhook_endpoints/{id}/rotate_secret returns a new secret once and keeps the old one
valid for an overlap window (default 24 hours), during which a delivery carries two v1=
values — so a receiver mid-deploy verifies with whichever secret its instance holds. Pass
{"overlap_seconds": 0} for an immediate cutover if a secret is compromised; the ceiling is
604800 (7 days).
During the overlap, verify against each v1= value and accept if any matches — the example
above already does this. A receiver that reads only the first signature will reject half its
deliveries for the length of the overlap window.
Delivery semantics
Deliveries are at-least-once and unordered. Dedupe on the tovr-event-id header.
There is no ordering field, and you do not need one. The payload is a pointer, not a value: every event says "refetch this shipment", and a refetch returns current state — so applying two events out of order converges on the same answer as applying them in order. (The feed's internal sequence is deliberately not published here; it is a platform-wide counter, and the change feed is where the cursor lives and stays opaque.)
Any 2xx is success. A 410 Gone from your endpoint disables it permanently — that is the way to
say "stop" without calling the API.
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | ~10 minutes |
| 5 | ~1 hour |
| 6 | ~6 hours |
| 7 | ~24 hours |
Delays of ten minutes and above carry ±10% jitter, so ten thousand deliveries to one recovering endpoint do not arrive as a single wall — the endpoint that just came back is the one least able to take a thundering herd. Do not build a test that asserts an exact later delay.
After the seventh the delivery is abandoned. Five abandoned deliveries in a row disable the
endpoint — about a week of an endpoint that is genuinely gone, rather than thirteen minutes of a
deploy. GET /v1/webhook_endpoints reports status, disabled_reason, last_error_code,
last_error_message and consecutive_failures; PATCH with {"status": "active"} re-enables.
last_error_message describes the failure — the status your endpoint returned, or that it could
not be reached. It does not contain your endpoint's response body.
What happens to events while an endpoint is disabled
Events for a disabled endpoint are held, not discarded, and are delivered when you re-enable it. So an endpoint disabled by repeated failures on a Friday and fixed the following Monday receives the weekend's events on re-enable, oldest first, subject to the same retry ladder.
Two limits are worth knowing:
- The backlog is kept for 14 days. Re-enable within that window and you lose nothing. Past it, the held deliveries are swept.
- An endpoint disabled by a
410 Goneis not held. A 410 means stop permanently, so its events are not buffered — re-enabling one starts from the present.
For both cases, the change feed is the recovery path: it retains 30 days, and re-enabling
returns a replay_from cursor pointing at the moment the endpoint went down.
PATCH /v1/webhook_endpoints/whe_… {"status": "active"}
{ "id": "whe_…", "status": "active", "replay_from": "AXk2…" }GET /v1/events?after=AXk2…replay_from is null when the endpoint was not disabled. Replaying is safe: every event
carries a stable tovr-event-id, so a consumer that already has one can discard the duplicate.
Endpoint requirements
https on port 443, no credentials in the URL, and a public address — endpoints resolving to
private, loopback or link-local addresses are refused. Redirects are not followed; a 3xx is
recorded as a failure.
Change feed
A resumable feed of what changed. Poll GET /v1/events with a cursor, hold open GET /v1/events/stream, or receive the same events as webhooks. Each event carries an id and a type, never a row — you refetch through the normal read.
Factoring partners
How a factoring company integrates with TOVR — one OAuth2 client per consenting organization, invoice.recorded webhooks signed with HMAC-SHA256, a refetch through GET /v1/invoices, catch-up from the change feed, and the rate limits that apply. Also covers what TOVR does not do, and what TOVR would need from you for a deeper integration.