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:
| header | meaning |
|---|---|
X-Novex-Timestamp | Unix seconds at signing time. |
X-Novex-Signature | v1= 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:
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.- Add the new secret to your server alongside the old one, and deploy. For a short window your verifier should accept either.
POST /webhook-endpoints/{id}/rotate-secret/activate— we start signing with the new secret and drop the old one.- 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
| type | raised when |
|---|---|
registration.created | someone registers for an event |
registration.updated | a registration’s details change |
registration.checked_in | an attendee is checked in |
event.published | an event goes live |
invoice.paid | an 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.