TOVRReference

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: 1
const [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.

AttemptDelay
1immediate
230 seconds
32 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 Gone is 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.

On this page