Skip to main content

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

EventTriggerTypical Action
verification.completedPerson/Company Verification journey finishes with resultsProcess results, update customer record
screening.completedPEP/sanctions screening finishesReview matches, update risk profile
journey.abandonedUser closes the verification UI without finishingSend reminder, create new journey
journey.expiredVerification URL expires (24h default)Generate new journey, notify user
check.failedA check fails after all retries are exhaustedAlert compliance team, investigate manually

Setup

1

Provide your endpoint URL

During onboarding, give Zenoo the HTTPS URL where you want to receive events:
https://api.yourapp.com/webhooks/zenoo
2

Receive your webhook secret

Zenoo generates an HMAC secret for signature verification. Store it securely (environment variable, secrets manager).
3

Implement signature verification

Every webhook includes an X-Zenoo-Signature header. Verify it before processing any payload:
server.js
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));
}
4

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
  }
}
FieldDescription
event_typeThe event name (e.g., verification.completed)
timestampISO 8601 timestamp of when the event occurred
journey_idUnique identifier for the verification journey
callback_referenceThe external_reference you provided when initiating the verification
statusCurrent journey status
dataEvent-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:
AttemptDelay After Failure
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry1 hour
5th through 168thEvery 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.
server.js
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 ResponseZenoo Behavior
200-299Delivery confirmed, no retry
3xxFollows redirect (up to 3 hops)
400-499Retries with backoff (may be transient)
500-599Retries with backoff
Timeout (>30s)Retries with backoff
Connection refusedRetries 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

  • Verify X-Zenoo-Signature on every incoming webhook
  • Use HTTPS for your webhook endpoint (no plain HTTP)
  • Respond with 200 within 30 seconds
  • Handle duplicate deliveries (idempotent processing)
  • Log failed signature verifications as security events
  • Process event payloads asynchronously (queue + worker)
  • Allowlist Zenoo IP ranges in production firewalls
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.

Signature format

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

1

Extract the signature header

Extract the X-Zenoo-Signature header value from the incoming request.
2

Isolate the hex digest

Remove the sha256= prefix to isolate the hex digest.
3

Compute the expected digest

Compute HMAC-SHA256 of the raw request body using your webhook secret.
4

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.
LanguageFunction
Node.jscrypto.timingSafeEqual()
Pythonhmac.compare_digest()
JavaMessageDigest.isEqual()
Gohmac.Equal()
RubyRack::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:
  1. Reject the request with 401 Unauthorized.
  2. Log the failure with the request timestamp, source IP, and headers.
  3. Do not process the payload.
  4. 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