Developer HubWebhooks (Inbound & Signed Outbound)
On this page0

Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.

Reference

Not a developer? The step-by-step guides begin at Start Here, and a problem on your screen is in When something goes wrong.

Webhooks (Inbound & Signed Outbound)

The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it.

Your instance receives webhooks internally and from providers you connect. Today there are two such endpoints: the signature completion callback, posted by the built-in Zonitix Sign engine when an offer is signed or declined, and an inbound-email endpoint that lets a candidate’s email reply land on their record in recruitment. The page ends with how a system you own verifies the two signed messages your instance sends out, with a complete receiver in Node.js and in Python.

What Does Not Exist Yet

With two exceptions, Zonitix does not currently send outbound webhooks to systems you own, and there is no general subscription API for platform events. The first exception is report delivery: a scheduled compliance report can POST its run to a Slack / Teams / HTTPS webhook (SSRF-guarded) — see Compliance Hub & Reporting. The second is audit-log forwarding: the audit log can be sent to an HTTPS webhook as signed NDJSON (HMAC-SHA256) — see the guide Forward logs to your SIEM. Beyond that, if you need to react to events in Zonitix today, the options are polling the API or reading the audit log. There is no general outbound event delivery, and no date for one is published.

How The Signature Callback Is Authenticated

The callback carries no user session, so it is authenticated with a signature over the request itself. A secret (DOCUSEAL_WEBHOOK_SECRET — the name is retained for backward compatibility) is provisioned on your instance at handover and is shared by Zonitix Sign and the recruitment service. Zonitix Sign never sends that secret: it uses it to compute an HMAC-SHA256 over a timestamp and the exact request body, and sends the result in X-Zonitix-Signature. The receiver recomputes it over the raw bytes it received and compares in constant time, so a changed body fails, and a call whose timestamp is more than five minutes off is refused as a replay. A call without a valid signature is refused with the text Invalid signature; the recruitment service answers 401 and the gateway passes it on to the sender as 400. The endpoint path /webhooks/docuseal is likewise kept for compatibility — it is now driven by Zonitix Sign, not by any external service.

// Endpoint on your instance:

POST https://<your-instance>-api.zonitix.com/api/v1/recruitment/webhooks/docuseal

// Headers Zonitix Sign sends (the secret itself is never transmitted):

X-Zonitix-Timestamp: <ms-timestamp>

X-Zonitix-Signature: t=<ms-timestamp>,v1=<hex HMAC-SHA256>

// What is signed, and how it is checked:

v1 = HMAC-SHA256(secret, `${t}.${rawBody}`) // |now - t| must be within 5 minutes

const valid = a.length === b.length && crypto.timingSafeEqual(a, b);

Fails Closed In Production

If DOCUSEAL_WEBHOOK_SECRET is not set, the endpoint returns 503 and refuses every webhook in production rather than accepting unauthenticated calls. Unauthenticated acceptance is a development-only behaviour.

Why This Matters

This callback drives real state: a forged form.completed would mark an offer accepted and kick off onboarding. That is precisely why it is signature-gated and rate-limited per IP.

Retries And Recovery

A callback that fails for a transient reason (no response, a timeout, 408, 429 or a 5xx) is retried with backoff, up to four attempts in total, each freshly signed. Receiving the same event twice is harmless. If every attempt is lost, a sweep on the recruitment side checks every offer still out for signature about every 20 minutes and applies the real outcome.

What Was Removed

Earlier releases also accepted the secret itself in X-DocuSeal-Signature, X-Webhook-Secret or Authorization: Bearer. That fallback is gone: a request carrying only the secret is refused with 401.

The Inbound-Email Endpoint

In plain words: when a recruiter emails a candidate from Zonitix, the candidate’s reply can be filed on that candidate’s message thread. For that to happen, the mail service that receives your replies has to pass each one to this endpoint. It is switched off until a shared secret has been stored for it; until then every call is ignored. There is no screen in the app for setting that secret today, so the endpoint cannot be switched on from the app yet.

// Endpoint on your instance (public, rate-limited per IP):

POST https://<your-instance>-api.zonitix.com/api/v1/recruitment/webhooks/inbound-email

// The shared secret, in a header or in the JSON body. Never in the query string.

X-Inbound-Secret: <shared secret>

// JSON body fields that are read (alternatives separated by |):

recipient | to // the address replied to: local+<reply-token>@domain

token // or the reply token on its own

from | sender

subject

text | body-plain | body

messageId | Message-Id | id // used to drop a repeat delivery

The endpoint always answers 200 with { ok: true, … }, so a mail provider never retries in a loop; the body says what happened. A stored message returns recorded and is audited as RECRUITMENT_CANDIDATE_MESSAGE_RECEIVED. Otherwise ignored is one of not_configured (no secret is set), bad_secret (also audited, as RECRUITMENT_INBOUND_EMAIL_REJECTED), no_token or no_match; a repeat of an already-stored message id returns deduped. The sender’s address and the message text are not written to the audit log. Stored values are cut at 320 characters for the sender, 500 for the subject and 100,000 for the body.

Verifying A Signed Delivery From Your Instance

In plain words: two things your instance sends to an address you own carry a signature, so your side can prove the message came from your instance and was not changed on the way: batches of audit-log events (Forward logs to your SIEM) and scheduled report deliveries (Compliance Hub & Reporting). Both are signed the same way, so one piece of code checks both. The receivers below are complete: save one, set the secret, run it.

// The header on every signed delivery:

X-Zonitix-Signature: t=<unix seconds>,v1=<64 hex characters>

// How v1 is computed. The body is the exact bytes of the request, not a re-serialised copy:

v1 = hex( HMAC-SHA256( secret, "<t>" + "." + <raw body> ) )

  1. Read the request body as raw bytes, before any JSON parsing.
  2. Split the header on commas, then each part on its first =, to get t and v1. Refuse the request when either is missing, or when v1 is not 64 hexadecimal characters.
  3. Refuse it when t is more than your tolerance away from your own clock, in either direction. This is what stops a captured delivery being replayed later. The receivers below use 300 seconds, the same value the product’s own reference check uses.
  4. Compute the HMAC yourself over t, a full stop, and the raw body, and compare it with v1 using a constant-time comparison.
  5. Only then parse the body. Answer with any 2xx status to accept it.

Node.js Receiver

No packages to install. Save as receiver.js and run ZONITIX_WEBHOOK_SECRET=… node receiver.js. It listens on port 8080 unless PORT is set; put it behind your own HTTPS endpoint, because your instance only delivers to an https address.

const crypto = require('crypto');
const http = require('http');

const SECRET = process.env.ZONITIX_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

function verify(secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) {
  const parts = {};
  for (const piece of String(header || '').split(',')) {
    const i = piece.indexOf('=');
    if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/i.test(parts.v1 || '')) return false;
  if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  const received = Buffer.from(parts.v1, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks); // the exact bytes received: never re-serialise before verifying
    if (!SECRET || !verify(SECRET, req.headers['x-zonitix-signature'], rawBody)) {
      res.writeHead(401).end();
      return;
    }
    const text = rawBody.toString('utf8');
    const events = req.headers['content-type'] === 'application/x-ndjson'
      ? text.split('\n').filter(Boolean).map((line) => JSON.parse(line)) // audit-log batch
      : [JSON.parse(text)];                                              // one report delivery
    for (const event of events) console.log(event.id ?? event.event, event.action ?? '');
    res.writeHead(204).end();
  });
}).listen(process.env.PORT || 8080);

Python Receiver

Standard library only. Save as receiver.py and run ZONITIX_WEBHOOK_SECRET=… python3 receiver.py.

import hashlib
import hmac
import json
import os
import re
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ.get("ZONITIX_WEBHOOK_SECRET", "")
TOLERANCE_SECONDS = 300


def verify(secret, header, raw_body, now_seconds=None):
    parts = {}
    for piece in (header or "").split(","):
        key, sep, value = piece.partition("=")
        if sep:
            parts[key.strip()] = value.strip()
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not re.fullmatch(r"[0-9]+", t) or not re.fullmatch(r"[0-9a-fA-F]{64}", v1):
        return False
    now_seconds = int(time.time()) if now_seconds is None else now_seconds
    if abs(now_seconds - int(t)) > TOLERANCE_SECONDS:
        return False
    signed = str(int(t)).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1.lower())


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        # The exact bytes received: never re-serialise before verifying.
        raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not SECRET or not verify(SECRET, self.headers.get("X-Zonitix-Signature"), raw_body):
            self.send_response(401)
            self.end_headers()
            return
        text = raw_body.decode("utf-8")
        if self.headers.get("Content-Type") == "application/x-ndjson":
            events = [json.loads(line) for line in text.split("\n") if line]  # audit-log batch
        else:
            events = [json.loads(text)]  # one report delivery
        for event in events:
            print(event.get("id", event.get("event")), event.get("action", ""))
        self.send_response(204)
        self.end_headers()


if __name__ == "__main__":
    HTTPServer(("", int(os.environ.get("PORT", "8080"))), Receiver).serve_forever()

The Three Signed Messages Compared

The offer-signing callback described at the top of this page uses the same header, but its timestamp is in milliseconds and your instance is the receiver, so you never verify it yourself. It is listed here because the unit is the one thing that differs, and the receivers above would refuse a millisecond timestamp as too far from the clock.

 Audit-log forwardingScheduled report deliveryOffer-signing callback
Who receives itA system you ownA system you ownYour instance itself
BodyEvents, one JSON object per line (application/x-ndjson), ending with a line breakOne JSON object (application/json)One JSON object
Unit of tUnix secondsUnix secondsMilliseconds
SecretThe signing secret you typed when adding the destination (at least 16 characters)whsec_ followed by 64 hexadecimal characters, made by your instance and shown once, when a webhook is first set on the schedule or the secret is rotatedSet on the instance at handover; you never handle it
Other headers sentX-Zonitix-Timestamp, X-Zonitix-Event-Count, X-Zonitix-Delivery-IdX-Zonitix-Event (when the body names an event), User-Agent: Zonitix-Webhooks/1X-Zonitix-Timestamp
Allowed clock differenceYour choice. The sender sets none; the reference check in the product uses 300 secondsYour choice. The reference check in the product uses 300 seconds5 minutes, enforced by the instance

What Your Receiver Should Expect

  • The same event can arrive twice. When an audit-log batch is not accepted, the same events are sent again later, signed afresh with a new t. Each event carries a numeric id; keep the ids you have stored and skip a repeat. Do not de-duplicate on X-Zonitix-Delivery-Id: it is a new random value on every attempt.
  • What an audit-log event holds. schema (zonitix.audit.v1), id, seq, timestamp, action, level (debug, info, warning, error or critical), actor, sourceService, ip, userAgent, correlationId, host, details, payload and chainHash. seq, timestamp, sourceService, ip, userAgent, correlationId and chainHash can be null. The test event sent from the destination’s Send test event button has id 0 and the action LOG_FORWARD_TEST.
  • How your answer is read, for audit-log batches. Any 2xx accepts the batch. 401, 403, 404, 408, 429, any 3xx and any 5xx are treated as a problem to retry, and the same events are sent again. Any other 4xx is read as your receiver refusing those events as content. So for an audit-log batch answer 401, not 400, when a signature does not verify. Your instance waits up to 15 seconds for the answer and does not follow redirects.
  • How your answer is read, for report deliveries. This differs. Any 2xx accepts the delivery. 408, 429, any 5xx, no answer within 10 seconds or a network error leaves it owed, and it is tried again: at most three attempts in total, the second due 15 minutes after the first and the third an hour after the second. A retry is sent only when the instance stored that run’s report files, and it is cancelled if the schedule is paused or deleted in the meantime. Any other 4xx, including the 401 these receivers send for a bad signature, is final for that run: it is not tried again. Redirects are not followed. No delivery commitment beyond this behaviour is published.
  • A report delivery with no signature header. If the instance cannot read a schedule’s stored secret, it sends that delivery without the header instead of not sending it. The receivers above refuse it with 401, which is the safe choice, and because a 401 is final for a report delivery that run’s webhook is not sent again. Rotate the schedule’s secret to restore signing for the runs that follow.
  • Keep the secret out of the code. Both receivers read it from an environment variable and refuse everything when it is empty.

Related reference