ioZen Docs

Webhooks

Receive real-time event notifications from ioZen via webhooks.

Webhooks allow you to receive real-time HTTP notifications when events occur in your ioZen workspace. Instead of polling the API, register a URL and ioZen will push events to you.

How It Works

  1. Register a webhook endpoint URL via the API or Workspace Settings → Webhooks
  2. Select which events you want to receive
  3. ioZen sends an HTTP POST to your URL when matching events occur
  4. Your server verifies the signature and processes the event

Event Catalog

EventTrigger
submission.completedA new submission has been completed (via UI or API)
submission.updatedA submission's data or status has been updated
contact.createdA new contact has been created
contact.updatedAn existing contact has been updated
intake-bot.status-changedAn intake bot's status changed (e.g., ACTIVE → PAUSED)

Payload Format

Every webhook delivery includes a JSON payload with this structure:

{
  "id": "evt_clxyz...",
  "type": "submission.completed",
  "created_at": "2026-02-23T10:00:00Z",
  "data": {
    "submission": {
      "id": "clxyz...",
      "intake_bot_id": "clxyz...",
      "status": "COMPLETED",
      "data": {
        "full_name": "Jane Doe",
        "email": "jane@example.com"
      },
      "created_at": "2026-02-23T10:00:00Z"
    }
  }
}

The data field contents vary by event type. For submission.* events it contains a submission object; for contact.* events it contains a contact object.

Delivery Headers

Each delivery includes these headers:

HeaderExampleDescription
Content-Typeapplication/jsonAlways JSON
X-IoZen-Event-Typesubmission.completedThe event type
X-IoZen-Event-Idevt_clxyz...Unique event ID for deduplication
X-IoZen-Signaturesha256=abc123...HMAC-SHA256 signature
X-IoZen-Timestamp1740300000Unix timestamp when the event was sent
User-AgentioZen-Webhooks/1.0Identifies ioZen as the sender

Signature Verification

Every webhook is signed using your endpoint's secret key. Always verify signatures before processing events to ensure the payload came from ioZen and hasn't been tampered with.

How Signing Works

The signature is computed over the string {timestamp}.{payload} where:

  • timestamp is the value from the X-IoZen-Timestamp header
  • payload is the raw JSON request body

This timestamp-prefixed scheme prevents replay attacks — you can reject events with timestamps too far in the past.

JavaScript (Node.js)

import crypto from 'crypto';

function verifyWebhook(rawBody, headers, secret) {
  const timestamp = headers['x-iozen-timestamp'];
  const signature = headers['x-iozen-signature'];

  const signatureInput = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signatureInput, 'utf8')
    .digest('hex');

  const expectedSignature = `sha256=${expected}`;

  // Use timing-safe comparison to prevent timing attacks
  if (expectedSignature.length !== signature.length) return false;

  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature, 'utf8'),
    Buffer.from(signature, 'utf8'),
  );
}

// Express handler example:
app.post('/webhooks/iozen', (req, res) => {
  const rawBody = req.body; // Use raw body, not parsed JSON
  const isValid = verifyWebhook(rawBody, req.headers, process.env.WEBHOOK_SECRET);

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(rawBody);

  switch (event.type) {
    case 'submission.completed':
      handleSubmission(event.data.submission);
      break;
    case 'contact.created':
      handleNewContact(event.data.contact);
      break;
  }

  res.status(200).send('OK');
});

Python

import hmac
import hashlib
import json

def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
    timestamp = headers.get("X-IoZen-Timestamp", "")
    signature = headers.get("X-IoZen-Signature", "")

    signature_input = f"{timestamp}.{raw_body.decode('utf-8')}"
    expected = hmac.new(
        secret.encode("utf-8"),
        signature_input.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    expected_signature = f"sha256={expected}"

    return hmac.compare_digest(expected_signature, signature)
# Flask handler example:
@app.route("/webhooks/iozen", methods=["POST"])
def handle_webhook():
    raw_body = request.get_data()
    if not verify_webhook(raw_body, request.headers, WEBHOOK_SECRET):
        return "Invalid signature", 401

    event = json.loads(raw_body)

    if event["type"] == "submission.completed":
        handle_submission(event["data"]["submission"])

    return "OK", 200

To prevent replay attacks, reject events with timestamps older than 5 minutes:

const timestamp = parseInt(headers['x-iozen-timestamp'], 10);
const now = Math.floor(Date.now() / 1000);
const fiveMinutes = 5 * 60;

if (Math.abs(now - timestamp) > fiveMinutes) {
  return res.status(401).send('Timestamp too old');
}

Retry Behavior

If your endpoint returns a non-2xx status code or fails to respond within 10 seconds, ioZen retries delivery:

  • Retries: Up to 5 attempts with exponential backoff
  • Concurrency: Max 3 concurrent deliveries per workspace to prevent overwhelming your server
  • Timeout: Your endpoint must respond within 10 seconds

Auto-Pause

After 10 consecutive delivery failures, the webhook endpoint is automatically paused to prevent further wasted delivery attempts. When paused:

  • No new events are sent to the endpoint
  • The endpoint status shows as "paused" in the dashboard and API
  • You can re-enable it from Workspace Settings → Webhooks

Fix the underlying issue (server down, URL changed, etc.) before re-enabling.

Testing Webhooks

From the Dashboard

Use the Send Test Event button in Workspace Settings → Webhooks to send a test payload to your endpoint. Test payloads include "is_test": true in the event data.

From the API

Register a webhook endpoint pointing to a request inspection tool like webhook.site or ngrok during development.

Idempotency

Each webhook delivery includes a unique X-IoZen-Event-Id. Use this ID to deduplicate events in case of retries — store processed event IDs and skip duplicates:

const eventId = headers['x-iozen-event-id'];
if (await isAlreadyProcessed(eventId)) {
  return res.status(200).send('Already processed');
}

Best Practices

  • Always verify signatures before processing events
  • Respond with 200 quickly — do heavy processing asynchronously after acknowledging receipt
  • Implement idempotency — use X-IoZen-Event-Id to handle duplicate deliveries
  • Log the X-IoZen-Event-Id for debugging and support requests
  • Use HTTPS endpoints — webhook URLs must use HTTPS in production

On this page