Webhooks
Webhooks deliver real-time notifications to your system when verification events occur. Instead of polling for results, configure a webhook endpoint and Zenoo will POST event payloads as they happen.
Events
| Event | Trigger | Typical Action |
|---|
verification.completed | Person/Company Verification journey finishes with results | Process results, update customer record |
screening.completed | PEP/sanctions screening finishes | Review matches, update risk profile |
journey.abandoned | User closes the verification UI without finishing | Send reminder, create new journey |
journey.expired | Verification URL expires (24h default) | Generate new journey, notify user |
check.failed | A check fails after all retries are exhausted | Alert compliance team, investigate manually |
Setup
Provide your endpoint URL
During onboarding, give Zenoo the HTTPS URL where you want to receive events:https://api.yourapp.com/webhooks/zenoo
Receive your webhook secret
Zenoo generates an HMAC secret for signature verification. Store it securely (environment variable, secrets manager).
Implement signature verification
Every webhook includes an X-Zenoo-Signature header. Verify it before processing any payload:const crypto = require("crypto");
function verifySignature(payload, signature, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(payload).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Return 200 within 30 seconds
Your endpoint must respond with a 200-299 status code within 30 seconds. If it does not, Zenoo treats the delivery as failed and retries.
Process events asynchronously. Accept the webhook, store the payload, and handle business logic in a background job.
Payload Structure
All webhook events share this envelope:
{
"event_type": "verification.completed",
"timestamp": "2026-01-15T14:35:00Z",
"journey_id": "jrn-abc123",
"callback_reference": "APP-2026-0123",
"status": "completed",
"data": {
// Event-specific payload
}
}
| Field | Description |
|---|
event_type | The event name (e.g., verification.completed) |
timestamp | ISO 8601 timestamp of when the event occurred |
journey_id | Unique identifier for the verification journey |
callback_reference | The external_reference you provided when initiating the verification |
status | Current journey status |
data | Event-specific payload (varies by event type) |
The callback_reference echoes back the value you set when initiating the verification. Use it to correlate webhook events with records in your system.
Event Payloads
verification.completed
Fires when a Person or Company Verification journey completes. The data field contains the full verification results, identical to the pull endpoint response.
{
"event_type": "verification.completed",
"timestamp": "2026-01-15T14:35:00Z",
"journey_id": "jrn-abc123",
"callback_reference": "APP-2026-0123",
"status": "completed",
"data": {
"case_reference": "AML-2026-0042",
"overall_verdict": "Pass",
"risk_tier": "Low",
"identity": { "verified": true, "decision": "Pass", "score": 95 },
"document": {
"verified": true,
"document_type": "Passport",
"tamper_result": "Pass"
},
"biometric": {
"liveness_verified": true,
"face_match": true,
"face_match_score": 96.2
},
"screening": {
"pep_status": "No Hit",
"sanctions_status": "No Hit",
"adverse_media_status": "No Hit"
}
}
}
screening.completed
Fires when standalone screening completes.
{
"event_type": "screening.completed",
"timestamp": "2026-01-15T14:30:20Z",
"journey_id": "jrn-def456",
"callback_reference": "SCREEN-2026-0099",
"status": "completed",
"data": {
"entity_name": "John Smith",
"entity_type": "person",
"pep_status": "No Hit",
"sanctions_status": "No Hit",
"adverse_media_status": "Hit",
"adverse_media_count": 2,
"screening_provider": "WorldCheck"
}
}
journey.abandoned
Fires when a user closes the verification UI or navigates away without completing it.
{
"event_type": "journey.abandoned",
"timestamp": "2026-01-15T15:00:00Z",
"journey_id": "jrn-abc123",
"callback_reference": "APP-2026-0123",
"status": "abandoned",
"data": {}
}
journey.expired
Fires when the 24-hour verification URL expires before the user completes the flow.
{
"event_type": "journey.expired",
"timestamp": "2026-01-16T14:35:00Z",
"journey_id": "jrn-abc123",
"callback_reference": "APP-2026-0123",
"status": "expired",
"data": {}
}
check.failed
Fires when a check fails after all automatic retry attempts are exhausted. This is a terminal state.
{
"event_type": "check.failed",
"timestamp": "2026-01-15T16:00:00Z",
"journey_id": "jrn-abc123",
"callback_reference": "APP-2026-0123",
"status": "failed",
"data": {
"check_type": "KYB_REGISTRATION",
"error_category": "Permanent",
"retry_count": 3,
"failure_reason": "Company not found in registry"
}
}
Do not retry the same check after receiving this event. Investigate the cause (invalid data, provider outage, missing information) and either correct the issue or escalate.
Retry Logic
If your endpoint does not return 200-299 within 30 seconds, Zenoo retries with increasing delays:
| Attempt | Delay After Failure |
|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 1 hour |
| 5th through 168th | Every 1 hour for 7 days |
After 7 days of consecutive failures, the webhook is marked as undeliverable. Contact Zenoo support to replay failed events.
Idempotency
Your webhook handler must be idempotent. Zenoo may deliver the same event more than once in edge cases (network timeouts, retry storms).
Use journey_id combined with event_type as a deduplication key to prevent processing the same event twice.
app.post("/webhooks/zenoo", async (req, res) => {
const { journey_id, event_type } = req.body;
const dedupeKey = `${journey_id}:${event_type}`;
// Check for duplicate delivery
const existing = await db.webhookEvents.findOne({ dedupeKey });
if (existing) {
// Already processed. Return 200 to stop retries.
return res.status(200).json({ received: true, duplicate: true });
}
// Store the event before processing
await db.webhookEvents.create({
dedupeKey,
journey_id,
event_type,
payload: req.body,
received_at: new Date()
});
// Process asynchronously
await queue.enqueue("process-webhook", req.body);
res.status(200).json({ received: true });
});
Always return 200 for duplicate events. Returning an error causes unnecessary retries.
Error Handling
How Zenoo responds to your endpoint’s status codes:
| Your Response | Zenoo Behavior |
|---|
200-299 | Delivery confirmed, no retry |
3xx | Follows redirect (up to 3 hops) |
400-499 | Retries with backoff (may be transient) |
500-599 | Retries with backoff |
| Timeout (>30s) | Retries with backoff |
| Connection refused | Retries with backoff |
Return 200 OK immediately, then process the event in a background job. This is the single most important implementation detail for reliable webhook handling.
Testing
Staging Webhooks
Configure your staging webhook endpoint during onboarding. All verification flows in the staging environment trigger the same webhook events as production, using mock provider data.
Local Development
Use a tunneling tool to expose your local server:
# Start your webhook server locally
node server.js # listening on port 3000
# Expose with ngrok
ngrok http 3000
# Configure your webhook URL as:
# https://abc123.ngrok.io/webhooks/zenoo
Webhook Replay
Contact Zenoo support to replay specific webhook events. This is useful for:
- Debugging payload parsing issues
- Testing idempotency handling
- Recovering from endpoint downtime
Security Checklist
Never skip signature verification. Without it, your endpoint accepts forged requests from anyone who knows your URL.
Signature Verification
Zenoo signs all outbound webhook payloads using HMAC-SHA256. Every webhook request includes a signature in the X-Zenoo-Signature header. Verify this signature before processing the payload.
X-Zenoo-Signature: sha256=a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890
The value is a sha256= prefix followed by the hex-encoded HMAC-SHA256 digest of the raw request body, computed with your webhook secret.
Verification steps
Extract the signature header
Extract the X-Zenoo-Signature header value from the incoming request.
Isolate the hex digest
Remove the sha256= prefix to isolate the hex digest.
Compute the expected digest
Compute HMAC-SHA256 of the raw request body using your webhook secret.
Compare using constant-time comparison
Compare the computed digest with the received digest using a constant-time comparison function. If the digests do not match, reject the request with 401 Unauthorized. Do not process the payload.
Code examples
const crypto = require("crypto");
function verifyWebhookSignature(rawBody, signature, secret) {
const expectedSig = crypto
.createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
const receivedSig = signature.replace("sha256=", "");
return crypto.timingSafeEqual(
Buffer.from(expectedSig, "hex"),
Buffer.from(receivedSig, "hex")
);
}
// Express middleware
app.post("/webhooks/zenoo", (req, res) => {
const signature = req.headers["x-zenoo-signature"];
const isValid = verifyWebhookSignature(
req.rawBody,
signature,
process.env.ZENOO_WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}
// Process the webhook
res.status(200).json({ received: true });
});
Express does not provide req.rawBody by default. Use the body-parser middleware with verify option or express.raw() to capture the raw body before JSON parsing:app.use(
express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
})
);
Constant-time comparison
Always use a constant-time comparison function when verifying signatures. Standard string equality operators (==, ===, .equals()) are vulnerable to timing attacks. An attacker can measure response times to incrementally guess the correct signature byte by byte.
| Language | Function |
|---|
| Node.js | crypto.timingSafeEqual() |
| Python | hmac.compare_digest() |
| Java | MessageDigest.isEqual() |
| Go | hmac.Equal() |
| Ruby | Rack::Utils.secure_compare() |
JWT-encoded webhooks
Some webhook configurations deliver payloads as JWT tokens instead of raw JSON. Zenoo auto-detects the format:
- If the request body starts with
eyJ and contains exactly 3 dot-separated segments, it is treated as a JWT.
- The JWT payload is decoded and verified against your webhook secret.
- Your verification logic does not need to change. Zenoo handles the decoding transparently.
You do not need to configure anything to enable or disable JWT encoding. The detection is automatic.
Handling verification failures
When signature verification fails:
- Reject the request with
401 Unauthorized.
- Log the failure with the request timestamp, source IP, and headers.
- Do not process the payload.
- Monitor for repeated failures, which may indicate an attack or a misconfigured secret.
If every webhook fails verification, check that your webhook secret matches the one Zenoo provided during onboarding. Secrets are environment-specific. A staging secret will not validate production webhooks.
Next Steps