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.

Webhooks are available on Pro (up to 3 endpoints) and Business (up to 10 endpoints) plans. Upgrade from Workspace Settings → Billing.

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. Optionally restrict deliveries to specific intake bots with intake_bot_ids (empty = all bots)
  4. ioZen sends an HTTP POST to your URL when matching events occur
  5. Your server verifies the signature and processes the event

Event Catalog

EventTrigger
submission.completedA submission has been completed (via UI or API)
contact.createdA new contact has been created

Payload Format

Every webhook delivery includes a JSON envelope with this structure. api_version identifies the payload contract (2026-08-27).

{
  "id": "evt_clxyz...",
  "type": "submission.completed",
  "api_version": "2026-08-27",
  "created_at": "2026-02-23T10:00:00Z",
  "data": {
    "submission": {
      "id": "clxyz...",
      "intake_bot_id": "clxyz...",
      "intake_bot_name": "Customer Onboarding",
      "status": "COMPLETED",
      "data": {
        "full_name": "Jane Doe",
        "email": "jane@example.com"
      },
      "ai_summary": "Promising case with a clear budget and timeline.",
      "ai_intelligence": {
        "overall_score": 82,
        "tier": "tier_hot",
        "dimensions": [
          { "id": "budget", "name": "Budget", "score": 90, "reasoning": "Stated budget above target." }
        ],
        "signals": ["Decision maker", "Explicit budget"],
        "analyzed_at": "2026-02-23T10:00:05Z"
      },
      "created_at": "2026-02-23T10:00:00Z"
    }
  }
}

data varies by event type:

Eventdata objectNotes
submission.completedsubmissionIncludes intake_bot_name and ai_intelligence (null when scoring is off or failed). Formerly named intelligence.
contact.createdcontactid, email, first_name, last_name, phone, company, created_at

Intake bot filter

Create or update an endpoint with intake_bot_ids to receive events only from those bots. An empty array (the default) means all bots in the workspace. IDs that do not belong to the workspace return 422 validation_failed.

API

MethodEndpointDescription
GET/v1/webhooksList endpoints
POST/v1/webhooksRegister an endpoint (secret returned once). Returns 201.
GET/v1/webhooks/:idGet an endpoint
PATCH/v1/webhooks/:idPartial update: events, description, status (active|paused), intake_bot_ids. URL is immutable (the signing secret is bound to it).
DELETE/v1/webhooks/:idDelete an endpoint
POST/v1/webhooks/:id/testSend a test event (data.is_test: true). Returns { success, status_code, error }.
GET/v1/webhooks/:id/deliveriesCursor-paginated delivery log
POST/v1/webhooks/:id/deliveries/:deliveryId/redeliverRebuild and deliver immediately (new event id). 404 if missing; 409 if the resource cannot be rebuilt.

All webhook API routes require the manage:webhooks scope. See the API reference.

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 and auto-pause

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

PolicyValue
Retries5 QStash attempts with exponential backoff
ConcurrencyMax 3 concurrent deliveries per workspace
Timeout10 seconds
Auto-pauseAfter 10 consecutive failures, the endpoint is paused and an alert email is sent to workspace owners/admins

When paused:

  • No new events are sent to the endpoint
  • Status is paused in the dashboard and API
  • Re-enable with PATCH /v1/webhooks/:id { "status": "active" } or from Workspace Settings → Webhooks

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

Redeliver a logged attempt with POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver. Payloads are rebuilt from the live resource (not stored). Manual redelivery uses a new event id.

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

POST /v1/webhooks/:id/test sends a test event and returns { success, status_code, error }. During development you can also register an endpoint pointing to webhook.site or ngrok.

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');
}

Manual redelivery is a new attempt and uses a new event id.

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