> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zenoo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Configure webhook endpoints to receive real-time notifications when verification events occur.

# 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

<Steps>
  <Step title="Provide your endpoint URL">
    During onboarding, give Zenoo the HTTPS URL where you want to receive events:

    ```
    https://api.yourapp.com/webhooks/zenoo
    ```
  </Step>

  <Step title="Receive your webhook secret">
    Zenoo generates an HMAC secret for signature verification. Store it securely (environment variable, secrets manager).
  </Step>

  <Step title="Implement signature verification">
    Every webhook includes an `X-Zenoo-Signature` header. Verify it before processing any payload:

    ```javascript server.js theme={null}
    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));
    }
    ```
  </Step>

  <Step title="Return 200 within 30 seconds">
    <Note>
      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.
    </Note>

    Process events asynchronously. Accept the webhook, store the payload, and handle business logic in a background job.
  </Step>
</Steps>

## Payload Structure

All webhook events share this envelope:

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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).

<Tip>
  Use `journey_id` combined with `event_type` as a deduplication key to prevent processing the same event twice.
</Tip>

```javascript server.js theme={null}
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                    |

<Warning>
  Return `200 OK` immediately, then process the event in a background job. This is the single most important implementation detail for reliable webhook handling.
</Warning>

## 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:

```bash theme={null}
# 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

<Warning>
  Never skip signature verification. Without it, your endpoint accepts forged requests from anyone who knows your URL.
</Warning>

## 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

<Steps>
  <Step title="Extract the signature header">
    Extract the `X-Zenoo-Signature` header value from the incoming request.
  </Step>

  <Step title="Isolate the hex digest">
    Remove the `sha256=` prefix to isolate the hex digest.
  </Step>

  <Step title="Compute the expected digest">
    Compute HMAC-SHA256 of the raw request body using your webhook secret.
  </Step>

  <Step title="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.
  </Step>
</Steps>

### Code examples

<CodeGroup>
  ```javascript Node.js theme={null}
  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 });
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
      expected = hmac.new(
          secret.encode('utf-8'),
          raw_body,
          hashlib.sha256
      ).hexdigest()

      received = signature.replace('sha256=', '')
      return hmac.compare_digest(expected, received)

  # Flask route
  @app.route('/webhooks/zenoo', methods=['POST'])
  def handle_webhook():
      signature = request.headers.get('X-Zenoo-Signature', '')
      is_valid = verify_webhook_signature(
          request.get_data(),
          signature,
          os.environ['ZENOO_WEBHOOK_SECRET']
      )

      if not is_valid:
          return jsonify({'error': 'Invalid signature'}), 401

      # Process the webhook
      return jsonify({'received': True}), 200
  ```

  ```java Java theme={null}
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.security.MessageDigest;

  public class WebhookVerifier {
      public static boolean verify(String rawBody, String signature, String secret)
              throws Exception {
          Mac mac = Mac.getInstance("HmacSHA256");
          mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
          byte[] hash = mac.doFinal(rawBody.getBytes("UTF-8"));

          String expected = bytesToHex(hash);
          String received = signature.replace("sha256=", "");

          return MessageDigest.isEqual(
              expected.getBytes("UTF-8"),
              received.getBytes("UTF-8")
          );
      }

      private static String bytesToHex(byte[] bytes) {
          StringBuilder sb = new StringBuilder();
          for (byte b : bytes) {
              sb.append(String.format("%02x", b));
          }
          return sb.toString();
      }
  }
  ```
</CodeGroup>

<Tip>
  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:

  ```javascript theme={null}
  app.use(
    express.json({
      verify: (req, res, buf) => {
        req.rawBody = buf;
      }
    })
  );
  ```
</Tip>

### Constant-time comparison

<Warning>
  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.
</Warning>

| Language | Function                       |
| -------- | ------------------------------ |
| Node.js  | `crypto.timingSafeEqual()`     |
| Python   | `hmac.compare_digest()`        |
| Java     | `MessageDigest.isEqual()`      |
| Go       | `hmac.Equal()`                 |
| Ruby     | `Rack::Utils.secure_compare()` |

### JWT-encoded webhooks

<Note>
  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.
</Note>

### Handling verification failures

<Warning>
  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.
</Warning>

## Next Steps

<Columns cols={2}>
  <Card title="Error Handling" icon="triangle-exclamation" href="/guides/error-handling">
    Handle API failures and retries
  </Card>

  <Card title="Result Delivery" icon="paper-plane" href="/guides/result-delivery">
    Compare push, pull, and ping+pull models
  </Card>

  <Card title="Idempotency" icon="copy" href="/guides/idempotency">
    Prevent duplicate verifications
  </Card>

  <Card title="Testing & Sandbox" icon="flask-vial" href="/testing/staging-environment">
    Test webhook delivery in staging
  </Card>
</Columns>
