Webhook integration¶
The platform delivers real-time event notifications to your backend via
outbound webhooks. When something happens to an applicant — verification
completes, status changes, AML screening returns hits, a questionnaire is
submitted — we send a signed POST to a URL you configure in the product
portal.
This guide covers configuration, signature verification, the request format, the event catalog, delivery semantics, secret rotation, and best practices for building a robust receiver.
How it works¶
Your backend the platform Outbox worker
| | |
| (in product portal) | |
| Settings → Webhooks | |
| url + events + secret | |
| | |
| (event happens) |
| |-- enqueue to outbox ---->|
| | |
|<-- POST {payload}, X-Webhook-Signature ------------|
|-- 2xx ----------------->| |
- A user with the
tenant_adminrole configures the webhook in the product portal under Settings → Webhooks: HTTPS endpoint URL, the event types you want to subscribe to, and any custom HTTP headers your endpoint needs. - The portal generates an HMAC-SHA256 signing secret. It is shown to the operator exactly once via a one-time-reveal flow — store it in your secrets manager immediately.
- As events occur on the platform, they are persisted to a durable outbox.
- A background worker delivers each event to your URL with at-least-once
semantics. Failed deliveries are retried with exponential back-off (full
schedule below). Successful delivery is any
2xxresponse within 5 seconds. - Your endpoint verifies the signature, processes the event, and returns
2xxquickly.
Webhook configuration is currently UI-only. The platform does not yet expose a
/v1/webhooks/...API for programmatic webhook management; integrations configure webhooks once via the product portal.
Setup¶
1. Configure in the product portal¶
In the product portal, navigate to Settings → Webhooks and:
-
Enter your HTTPS endpoint URL.
-
HTTP is rejected. The URL must be
https://. -
Loopback hostnames (
localhost,127.0.0.1,::1), private IPs (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16), CGNAT (100.64.0.0/10), link-local (169.254.0.0/16— including AWS / Azure / GCP metadata endpoints) and IPv6 ULA / multicast addresses are also rejected. We resolve the hostname at save time, so an A record pointing into a private range is caught. -
Pick the event types you want to subscribe to. By default you'll receive all events; trim the list if you only care about specific transitions (e.g. just
applicantReviewedfor billing logic). -
Optionally add custom HTTP headers — useful for partner-side auth (
X-Tenant-Id,X-Source: compliance, etc.). The following header names are reserved and cannot be set: -
Anything starting with
X-Webhook-(our system headers). - Hop-by-hop / framing headers:
Host,Content-Length,Content-Type,Connection,Transfer-Encoding,Upgrade,Expect. -
Credential carriers:
Authorization,Proxy-Authorization,Cookie,Set-Cookie, headers matchingX-Api-Key*,X-Auth-*,X-Access-Token*,X-Session-*, or anything containing-bearer,-secret, or-password. -
Save. The signing secret is generated on first save.
2. Reveal and store the signing secret¶
Click Rotate / View Secret. The portal will show the secret to you exactly once. Copy it into your secrets manager (env var, Vault, Secrets Manager, etc.) immediately — closing the dialog without copying means you have to rotate again to get a new value.
The secret is stored encrypted at rest on the platform side using AES-256-GCM with a separate KMS key.
3. Test the integration¶
Click Test webhook in the portal. The platform sends a sample payload
with eventType set to whatever you've selected (default
applicantReviewed). The dialog shows the HTTP status code and body your
endpoint returned. Use this to verify your signature check before going live.
Request format¶
Every delivery is an HTTPS POST with these headers:
| Header | Value |
|---|---|
Content-Type |
application/json; charset=utf-8 |
User-Agent |
NeoxCompliance-Webhook/1.0 (subject to change — don't pin on it) |
X-Webhook-Event |
The event type, e.g. applicantReviewed. |
X-Webhook-Delivery-Id |
Per-attempt id in the form <outboxEntryId>-<attemptNumber> (e.g. f3a…-1, f3a…-2). Each retry of the same logical event gets a distinct value. |
X-Webhook-Event-Id |
The logical event id (the outbox entry GUID), stable across all retries of the same event. Use this as your idempotency key — the same event may be delivered more than once on retry (network blip, janitor recovery, manual portal re-fire). |
X-Webhook-Timestamp |
Unix epoch in milliseconds at the moment of delivery (informational — the same value is also encoded inside the signature's t= field, which is what verification must use). |
X-Webhook-Signature |
t=<timestampMs>,v1=<lowercase hex> where the hex is HMAC-SHA256(secret, "{timestampMs}.{rawBody}"). The timestamp is bound into the HMAC so an attacker cannot replay a captured (body, signature) by substituting a fresh timestamp. See Signature verification. |
X-Webhook-Signature-Previous |
(only during the rotation grace window) — same format, computed with your previous signing secret. See Secret rotation. |
Drop-in compatible with the de-facto KYC webhook standard. The payload shape (top-level
applicantId/inspectionId/type/reviewStatus+ nestedreviewResult.reviewAnswerGREEN/RED) mirrors the format used by major KYC providers in the market. Handler code written against any of those providers usually works against this platform with only the URL and signing secret changed.
The body is JSON. Top-level shape (default payloadShape: "sumsub"):
{
"applicantId": "9d8b5c84-...-b2",
"inspectionId": "9d8b5c84-...-b2",
"externalUserId": "user_42",
"applicantType": "individual",
"levelName": "kyc-basic",
"type": "applicantReviewed",
"reviewStatus": "completed",
"reviewResult": {
"reviewAnswer": "GREEN",
"rejectLabels": [],
"reviewRejectType": null
},
"createdAt": "2026-05-05T10:15:32.418Z",
"createdAtMs": 1714904132418,
"correlationId": "8a4f...e0c1",
"clientId": "your-product-id",
"sandboxMode": false
}
Field notes:
reviewResultis always present on events that carry a decision (applicantReviewed,applicantWorkflowFailed). All three sub-fields are always present —rejectLabelsis[]when there are no labels, nevernullor absent.- Events that aren't decision events (e.g.
applicantCreated,applicantPersonalInfoChanged) carry a flat payload withoutreviewResult. See the catalog below for per-event extras. correlationIdis unique per event (regardless of retry count); useX-Webhook-Delivery-Idfor per-attempt idempotency.createdAtis an ISO-8601 UTC instant with the trailingZ.createdAtMsis the same moment as a Unix epoch in milliseconds — present so handler code that prefers numeric timestamps can skip the parse.clientIdis your product id. Sumsub-style handler libraries use this field to disambiguate the sending tenant; for this platform it is exactlyproductId.sandboxModeistruewhen the event originated from sandbox traffic andfalsefor production. Use it to route test deliveries into a separate store without parsing the URL.
Payload shape toggle¶
If your handler explicitly rejects extra fields (some strict-schema decoders
do), open Settings → Webhooks and switch Payload shape to neox. The
narrower shape drops clientId, sandboxMode, and createdAtMs, leaving
only the original Neox fields — everything else (signing, headers, retries)
is identical. The default is sumsub and we recommend keeping it: the
extra fields are additive and let you reuse handler code written for other
KYC providers with only URL + secret changed.
Signature verification¶
The signature uses a versioned scheme where the delivery timestamp is
cryptographically bound into the HMAC — an attacker who captures a valid
(body, signature) pair cannot replay it by substituting a fresh timestamp.
Header format:
The signed material is {timestampMs}.{rawBody}, HMACed with your signing
secret using SHA-256, lowercase hex:
The X-Webhook-Timestamp header carries the same timestampMs as a
convenience for clients that don't want to parse the signature header — but
you must verify against the timestamp encoded inside the signature header
(the t= part), not the standalone header, otherwise an attacker can swap
the header timestamp without affecting verification.
Hash the bytes you received, not a re-serialized version. Re-encoding the JSON will change whitespace and break the comparison.
Node.js¶
import crypto from 'node:crypto'
function parseSig(header) {
// "t=1717...,v1=4f8c..." → { t: "1717...", v1: "4f8c..." }
return Object.fromEntries(header.split(',').map(p => p.split('=')))
}
function verify(rawBody, signatureHeader, secret, toleranceMs = 5 * 60 * 1000) {
const parts = parseSig(signatureHeader || '')
if (!parts.t || !parts.v1) return false
// Reject stale deliveries before doing the HMAC.
const ageMs = Math.abs(Date.now() - parseInt(parts.t, 10))
if (ageMs > toleranceMs) return false
const signed = `${parts.t}.${rawBody}` // String or Buffer; both work
const expected = crypto
.createHmac('sha256', Buffer.from(secret, 'base64'))
.update(signed)
.digest('hex')
const a = Buffer.from(parts.v1, 'hex')
const b = Buffer.from(expected, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Python¶
import base64, hmac, hashlib, time
def verify(raw_body: bytes, signature_header: str, secret_b64: str, tolerance_ms: int = 5 * 60 * 1000) -> bool:
parts = dict(p.split("=", 1) for p in (signature_header or "").split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
# Reject stale deliveries before doing the HMAC.
if abs(int(time.time() * 1000) - int(t)) > tolerance_ms:
return False
key = base64.b64decode(secret_b64)
signed = t.encode() + b"." + raw_body
expected = hmac.new(key, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
The signing secret is base64-encoded random bytes — decode before using as the HMAC key.
Replay protection¶
The t= timestamp inside the signature header is the delivery time in Unix
milliseconds. Both verification snippets above already reject deliveries
older than a 5-minute tolerance — that bounds how long a captured request
remains replayable. Adjust the tolerance to your clock-skew tolerance + how
long you're willing to leave an attacker's replay window open; 5 minutes
matches Stripe's default.
Event catalog¶
Event names use camelCase (no dots).
Applicant lifecycle¶
| Event | When |
|---|---|
applicantCreated |
A new applicant is created. |
applicantPersonalInfoChanged |
Applicant personal info is updated. Payload includes changedFields. |
applicantLevelChanged |
Applicant verification level changes. |
applicantActivated |
An applicant is reactivated after being deactivated. |
applicantDeactivated |
An applicant is deactivated (cannot start verifications). |
applicantDeleted |
An applicant is soft-deleted (GDPR). |
applicantPersonalDataDeleted |
An applicant's PII is anonymised (GDPR right-to-erasure). |
applicantReset |
An applicant's verification state is reset. |
Verification (IDV) lifecycle¶
| Event | When |
|---|---|
applicantAwaitingOperatorReview |
The verification is parked in the operator manual-review queue. The applicant has no action to take; a tenant reviewer needs to approve or reject from the product portal. |
applicantOnHold |
A verification has been manually placed on hold pending review. |
applicantReviewed |
The verification has reached a terminal review decision. Includes reviewResult.reviewAnswer (GREEN / RED). When the level included a questionnaire, the envelope also carries questionnaireScore and questionnaireScoreBand. AML and adverse-media outcomes are rolled into this event — branch on reviewResult.reviewAnswer / reviewResult.riskLabel. Subscribe to this as the single "verification done" signal. |
applicantWorkflowFailed |
A verification workflow ended in technical failure (distinct from a user-side rejection). |
applicantExpired |
A verification session was expired by the stale-session sweeper. reviewResult.reviewAnswer is RED with reviewRejectType EXPIRED — mint a fresh link if you want the applicant to retry. |
applicantManagedComplianceDecided |
Neox Managed Compliance reviewers reached a decision on an applicant your team escalated. Payload includes managedComplianceDecision (approve / reject), managedComplianceDecisionReason, managedComplianceDecidedAtUtc. Subscribe if your product has its own approval gate that should mirror the Managed Compliance verdict. |
Questionnaire submission is not a separate event. The questionnaire result is rolled into the terminal
applicantReviewedenvelope asquestionnaireScore+questionnaireScoreBand. TreatapplicantReviewedas the single "verification done" signal regardless of whether the level includes a questionnaire.If you need an event we don't yet emit, get in touch — the catalog is deliberately conservative and we add events when there's a real integration need.
Handling rejection — retry recipe¶
When applicantReviewed arrives with reviewResult.reviewAnswer === "RED",
or when applicantWorkflowFailed arrives, the user's verification ended in
rejection. Inside the SDK the user sees a terminal "Verification not
approved" screen with the reason — but the SDK has no built-in retry button.
Recovery is your tenant code's job. Three patterns, pick by rejectLabels:
Soft rejection — retry recommended¶
For labels like BLURRY_IMAGE, LOW_QUALITY_PHOTO, WRONG_DOCUMENT_TYPE,
the user probably just needs a clean attempt. Recipe:
- On webhook receipt with a soft-reject label, mint a fresh verification link for the same applicant:
POST /v1/applicants/{externalUserId}/verifications
X-Api-Key: pk_…
X-Api-Secret: ps_…
Content-Type: application/json
{ "verificationLevelName": "KYC_01" }
-
The response carries
url(the new short link) — send it to the user via your own channel (email, SMS, in-app banner) with a friendly note: "We need a clearer photo of your ID — try again here." -
The new attempt is a fresh
enrollmentsrow; the original applicant keeps its history. Webhooks for the new attempt fire as usual.
Hard rejection — escalate, don't auto-retry¶
For labels like FORGED_DOCUMENT, CRIMINAL_RECORD, SANCTIONS_HIT,
auto-retry is the wrong move. Recipe:
- Notify your fraud / compliance ops queue.
- Block the user account / refuse to issue another link until a human reviews.
- Do not call
POST /verificationsautomatically — it would re-issue a link and let the user keep trying. Use the operator portal's manual-approval flow if a human decides the rejection was a false positive.
Operator override (false positive)¶
When your compliance team decides a hard rejection was wrong:
POST /v1/applicants/{externalUserId}/decision
{ "action": "approve", "moderationComment": "Manual override — receipt #..." }
This fires applicantReviewed again with reviewAnswer === "GREEN" and
flips the applicant to approved without requiring the user to re-submit.
Questionnaire data is preserved. Whether the rejection was IDV-driven or post-questionnaire, the
applicantReviewedenvelope still carriesquestionnaireScore+questionnaireScoreBand(when the level had a questionnaire). You don't have to redo the questionnaire on retry — the new attempt picks up where the user left off if the level config calls for it.
Delivery semantics¶
- At-least-once. A delivery is "successful" iff the receiver returned a
2xxstatus within 5 seconds. Anything else (4xx/5xx, network error, timeout, redirect) is treated as failure and retried. - Retries. Per-attempt back-off: 10 s, 30 s, 1 min, 5 min, 15 min.
After 5 attempts the delivery is dead-lettered — visible in the portal
delivery history with status
DeadLettered. From there an operator can click Retry to enqueue a fresh delivery (which creates a new outbox row linked back to the original viaretryOf). - Janitor. A background sweep every 5 minutes picks up any deliveries
stranded mid-flight (worker crash → row stuck in
Processing) or past their scheduled retry, and re-queues them. You should not see this externally — it just means a deployment-time worker restart can't lose events. - Order is not guaranteed. Two events for the same applicant may arrive
out of order. Use
createdAtto reconcile. - No redirects. The HTTP client refuses to follow
3xxresponses — configure your final URL directly. A redirect counts as a delivery failure. - No bodies > 64 KB. We read at most 64 KB of your response (and only store the first 500 chars for audit). Send a small ack — anything larger is wasted.
- Idempotency.
X-Webhook-Delivery-Idis unique per delivery attempt. Use it as the deduplication key on your side. Retries reuse the samecorrelationId(event identity) but a newX-Webhook-Delivery-Id(attempt identity). Manual retries from the portal create a NEW outbox entry, so they get fresh ids of both kinds.
Secret rotation¶
Operators rotate the signing secret from the portal at any time. The mechanism is a two-phase reveal to keep the secret out of browser history / proxy logs / monitoring captures:
- Click Rotate. The portal calls
POST /api/product-portal/webhooks/secret/rotateand gets back a short-lived (5 min) reveal token. No secret is in this response. - Click Reveal. The portal calls
POST /api/product-portal/webhooks/secret/revealwith the token and gets back the new secret, exactly once. The token is then burned.
For the next 24 hours after a rotation (configurable, default 24h), the platform sends two signature headers on every delivery:
X-Webhook-Signature— signed with the new secret.X-Webhook-Signature-Previous— signed with the old secret.
Update your stored secret to the new value during the grace window. Your verification logic should accept either signature during a brief overlap to avoid drops. After the grace window expires, only the new signature is sent.
Source IPs (optional firewall allowlist)¶
If you want to allowlist webhook traffic on your inbound firewall, accept deliveries from the platform's NAT-egress range. The ranges currently in effect for this deployment:
31.210.65.157
The authoritative, always-current list per environment is published as live JSON on a public, unauthenticated endpoint so you can pull it directly into your security group / WAF / reverse-proxy ACL automation:
Example response:
Sandbox and production share the same egress, so this is a single list. The array contains a mix of CIDR ranges and individual IPv4 addresses; treat each entry as an opaque firewall rule and feed them through unchanged. An empty array means the operator hasn't published an explicit egress range — fall back to your own hostname-based ACL or, preferably, rely on signature verification (see below) instead of IP filtering.
Poll on whatever cadence matches your change-management process. The list is intended to be stable across deployments, but we occasionally cycle outbound IPs when scaling NAT gateways; sync once a day and you'll catch changes well before stale rules cause a drop.
IP allowlisting is OPTIONAL and complementary — signature verification is the primary authentication. Don't drop signature checks in favour of IP-only validation: signatures protect against payload tampering even from an allowed IP, and our IP ranges may rotate.
Best practices¶
- Respond fast. Return
200 OKwithin a few seconds and do work asynchronously (queue the event for processing and reply immediately). If you hold the request open too long the worker times out and retries — the same logical event then runs twice on your side. - Verify the signature on every request. Reject anything missing or
invalid with
401. Don't fall back to "trust the IP" — IPs can change. - Use a
Delivery-Idledger. Insert eachX-Webhook-Delivery-Idinto a table with a unique constraint and reject duplicates. This protects against retry-storms during outages on your side. - Validate the timestamp. Reject deliveries with skew > 5 min — protects against replayed payloads if a signature key ever leaks.
- Use TLS-only and a real cert. We refuse to send to plain
http://URLs. Self-signed certs work but you'll have a harder time debugging delivery failures. - Stop the bleed during incidents. If your endpoint is broken, an operator can flip Webhook is active off in the portal — events will pause queueing. When you're ready, re-enable; events that fired during the pause will not be back-filled (they were never queued). For events you did receive but failed to process, manually retry from the portal's delivery history.