Skip to Content
APIWebhooks

Webhooks

Instead of polling the API for changes, have Novex POST an event to your server the moment it happens. You subscribe an HTTPS endpoint, we sign every delivery, and you verify the signature before you trust the body.

Subscribing

Create an endpoint from Settings → Webhooks, or with the API:

POST /api/public/v1/webhook-endpoints Authorization: Bearer nvx_live_… Content-Type: application/json { "url": "https://example.com/novex-hook", "description": "Production" }

The response carries a secret. It is returned by this call and by rotate-secret, and by nothing else — no later read includes it. Store it before you close the tab.

The URL must be https:// and must resolve to a public address; we check that when you subscribe, so a typo fails there with a sentence rather than failing silently at every delivery.

A workspace may hold up to 10 endpoints.

Verifying a delivery

Every request carries two headers:

headermeaning
X-Novex-TimestampUnix seconds at signing time.
X-Novex-Signaturev1= followed by the hex HMAC-SHA256 of {timestamp}.{raw body}.

Verify against the raw body, before any JSON parsing — a re-serialised body will not match.

import { createHmac, timingSafeEqual } from 'node:crypto' export function verify(rawBody, headers, secret) { const t = Number(headers['x-novex-timestamp']) const signature = headers['x-novex-signature'] // The tolerance is ABSOLUTE — |now − t| ≤ 300, not "t is not too old". // A past-only check rejects every delivery from a sender whose clock is a // second ahead of yours. if (!Number.isFinite(t) || Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false const expected = 'v1=' + createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex') const a = Buffer.from(expected) const b = Buffer.from(signature ?? '') // Length first: timingSafeEqual throws on a mismatch. if (a.length !== b.length) return false return timingSafeEqual(a, b) }

Compare in constant time. A === on a signature leaks a little of the answer per request.

Rotating a secret

Rotation is two calls, and the order is what keeps deliveries working:

  1. POST /webhook-endpoints/{id}/rotate-secret — mints the new secret and returns it once. Deliveries keep being signed with the current secret. Nothing breaks yet.
  2. Add the new secret to your server alongside the old one, and deploy. For a short window your verifier should accept either.
  3. POST /webhook-endpoints/{id}/rotate-secret/activate — we start signing with the new secret and drop the old one.
  4. Remove the old secret from your server.

We sign with one secret and send one v1= value. We do not dual-sign: a receiver that accepts any of several candidates keeps accepting a secret that is being retired, and a rotation window that never closes is not a rotation.

An unactivated rotation expires after 7 days and is discarded. Nothing is auto-promoted — if it expires, start a new rotation. activate refuses an expired one with pending_secret_expired.

Retries, and what dead-letter means

A delivery is the initial send plus five retries — six sends in total — with gaps of 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, each jittered by ±20%. After the sixth, the delivery is marked DEAD_LETTER and is not retried again.

We treat 2xx as success and everything else as a failure, including a redirect: we do not follow 3xx on a webhook.

If an endpoint fails continuously for 7 days we switch it off and email your admins. Being disabled silently is how you find out three weeks later that you have been missing data.

Fix the endpoint, then re-enable it under Settings → Webhooks. Anything still inside the 7-day payload window can be replayed from the delivery log; anything past it cannot, because the body is gone — see below.

Deliveries can arrive out of order

We do not promise ordering. Two events about the same object can arrive in either order, and a retry of an older one can land after a newer one.

The fix on your side is one line: ignore an event whose createdAt/updatedAt is older than the state you already hold.

Each request also carries X-Novex-Delivery-Id, stable across retries of the same delivery — use it to discard a duplicate rather than processing it twice.

The delivery log

GET /webhook-endpoints/{id}/deliveries returns what we tried and what happened: the event type, status, attempt count, response code, lastError, and when the next retry is due.

lastError is text from your server, captured as-is and truncated. It is there to help you debug; it is not a Novex error code.

The same log is in the app under Settings → Webhooks, one endpoint at a time, with a Replay button on each delivery whose body we still hold.

Payload retention and replay

We keep a copy of what we sent for 7 days, then drop the body and keep the record — what was sent, when, whether it worked — for good.

POST /webhook-endpoints/{id}/deliveries/{deliveryId}/replay re-sends that stored body under a new delivery id. Because replay re-sends what we sent rather than re-projecting current state, it is bounded by that retention: past 7 days it answers 409 PAYLOAD_NO_LONGER_RETAINED, and the read API is the right way to ask what an object looks like now.

The refusal is on the delivery’s age, not on whether our nightly cleanup has reached it yet — so a delivery eight days old is refused whether or not its body is still on our disk. You get the same answer at the same time every time.

⚠️ A replay starts a new 7-day clock. The re-sent delivery is a new record with its own retention, so replaying something on day six keeps that copy for seven more days. If you are replaying to debug, that is usually what you want; if the body describes a person, it is worth knowing that the copy now outlives the original.

Erasing a person inside that window

One half of this is a promise and the other half is a limit. Both matter.

If you erase a person while copies are still retained, those copies are erased with them. You do not have to wait for the seven days to expire, and you do not have to find the affected deliveries yourself. The delivery records survive — event type, status, timestamps — holding nothing about that person.

We can only erase our own copies. A webhook we already delivered is on your server. Erasing a person in Novex does not reach it, and nothing in Novex can. Deleting it on your side is your own step — and if you forward webhook bodies onward, theirs too.

That limit is not a gap we intend to close; it is what delivering data to another system means. If you are the data controller, the matching deletion on your side is part of honouring the request.

Events

typeraised when
registration.createdsomeone registers for an event
registration.updateda registration’s details change
registration.checked_inan attendee is checked in
event.publishedan event goes live
invoice.paidan invoice is settled

An endpoint with no event types selected receives all of them.

{ "id": "whd_2f9…", "type": "registration.checked_in", "createdAt": "2026-09-05T12:04:11.000Z", "data": { "registration": { "id": "reg_…", "name": "…", "email": "…", "status": "CHECKED_IN" } } }

data matches the shape the read API returns for the same object, so a handler written against GET /registrations/{id} works here unchanged.

Next

Last updated on