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
card.phase_changedA case moved to another phase of its Process Board
card.assignedThe owners of a case changed, by a person or by an assignment rule

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"
    }
  }
}

Case events

card.phase_changed is sent once for each committed move of a case to another phase. from_phase and to_phase carry the phase names as they were at move time.

{
  "id": "evt_clxyz...",
  "type": "card.phase_changed",
  "api_version": "2026-08-27",
  "created_at": "2026-10-27T10:00:01Z",
  "data": {
    "card": {
      "id": "clcase123...",
      "board_id": "clboard123...",
      "phase": { "id": "clphase_quoted...", "name": "Quoted" },
      "title": "Jane Doe - Annual plan",
      "description": "Annual plan for Jane Doe",
      "status": "active",
      "priority": "high",
      "amount": "1500.00",
      "currency": "USD",
      "assignees": [{ "id": "clprofile123...", "name": "Jane Doe" }],
      "tags": [{ "id": "cltag_won...", "name": "Won" }],
      "intelligence": { "tier": "tier_hot", "tier_name": "Hot", "score": 82 },
      "source": { "type": "intake_bot_submission", "id": "clsub123..." },
      "submission_ids": ["clsub123..."],
      "contacts": [
        {
          "id": "clcontact123...",
          "is_primary": true,
          "contact": {
            "id": "clcontact123...",
            "first_name": "Jane",
            "last_name": "Doe",
            "email": "jane@example.com",
            "phone": "+15551234567",
            "company": null,
            "country": "US",
            "custom_fields": {},
            "tags": [],
            "opt_in": null,
            "submission_count": 1,
            "case_count": 1,
            "is_test": false,
            "last_activity_at": null,
            "created_at": "2026-10-20T09:00:00Z",
            "updated_at": "2026-10-20T09:00:00Z"
          }
        }
      ],
      "records": [
        {
          "id": "clrecord123...",
          "is_primary": true,
          "record_type": { "id": "cltype123...", "key": "voucher", "name": "Voucher" },
          "record": {
            "id": "clrecord123...",
            "record_type_id": "cltype123...",
            "values": { "voucher_number": "V-000123" },
            "schema_version": 1,
            "parent_record_id": null,
            "contact_ids": ["clcontact123..."],
            "is_test": false,
            "created_at": "2026-10-20T09:00:00Z",
            "updated_at": "2026-10-27T10:00:00Z"
          }
        }
      ],
      "comment_count": 0,
      "is_test": false,
      "first_human_reply_at": "2026-10-20T09:12:00Z",
      "awaiting_reply_since": null,
      "due_date": null,
      "completed_at": null,
      "created_at": "2026-10-20T09:00:00Z",
      "updated_at": "2026-10-27T10:00:00Z"
    },
    "from_phase": { "id": "clphase_new...", "name": "New" },
    "to_phase": { "id": "clphase_quoted...", "name": "Quoted" },
    "actor": { "type": "member", "id": "clprofile123...", "name": "Jane Doe" },
    "occurred_at": "2026-10-27T10:00:00Z"
  }
}

card.assigned is sent once for each change to the owners of a case, whether made by a person or by an assignment rule. Removing an owner without adding another still sends the event, with added empty.

{
  "id": "evt_clabc...",
  "type": "card.assigned",
  "api_version": "2026-08-27",
  "created_at": "2026-10-27T10:05:01Z",
  "data": {
    "card": {
      "id": "clcase123...",
      "board_id": "clboard123...",
      "phase": { "id": "clphase_quoted...", "name": "Quoted" },
      "title": "Jane Doe - Annual plan",
      "description": "Annual plan for Jane Doe",
      "status": "active",
      "priority": "high",
      "amount": "1500.00",
      "currency": "USD",
      "assignees": [{ "id": "clprofile123...", "name": "Jane Doe" }],
      "tags": [{ "id": "cltag_won...", "name": "Won" }],
      "intelligence": { "tier": "tier_hot", "tier_name": "Hot", "score": 82 },
      "source": { "type": "intake_bot_submission", "id": "clsub123..." },
      "submission_ids": ["clsub123..."],
      "contacts": [
        {
          "id": "clcontact123...",
          "is_primary": true,
          "contact": {
            "id": "clcontact123...",
            "first_name": "Jane",
            "last_name": "Doe",
            "email": "jane@example.com",
            "phone": "+15551234567",
            "company": null,
            "country": "US",
            "custom_fields": {},
            "tags": [],
            "opt_in": null,
            "submission_count": 1,
            "case_count": 1,
            "is_test": false,
            "last_activity_at": null,
            "created_at": "2026-10-20T09:00:00Z",
            "updated_at": "2026-10-20T09:00:00Z"
          }
        }
      ],
      "records": [
        {
          "id": "clrecord123...",
          "is_primary": true,
          "record_type": { "id": "cltype123...", "key": "voucher", "name": "Voucher" },
          "record": {
            "id": "clrecord123...",
            "record_type_id": "cltype123...",
            "values": { "voucher_number": "V-000123" },
            "schema_version": 1,
            "parent_record_id": null,
            "contact_ids": ["clcontact123..."],
            "is_test": false,
            "created_at": "2026-10-20T09:00:00Z",
            "updated_at": "2026-10-27T10:00:00Z"
          }
        }
      ],
      "comment_count": 0,
      "is_test": false,
      "first_human_reply_at": "2026-10-20T09:12:00Z",
      "awaiting_reply_since": null,
      "due_date": null,
      "completed_at": null,
      "created_at": "2026-10-20T09:00:00Z",
      "updated_at": "2026-10-27T10:00:00Z"
    },
    "added": [{ "id": "clprofile123...", "name": "Jane Doe" }],
    "removed": [],
    "actor": { "type": "system", "id": null, "name": null },
    "assignment": { "automatic": true, "rule_name": "Example rule" },
    "occurred_at": "2026-10-27T10:05:00Z"
  }
}

data.card is the case in its current state (for card.assigned, with the owners after the change). amount is a decimal string such as "1500.00" or null, and currency is the board's ISO 4217 code. Case webhooks always include the linked contact and record. Adding a tag does not send a webhook. Tag the case before you move it, or read the tags from the API.

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. Sent for every new contact. See Contact events.
card.phase_changedcard, from_phase, to_phase, actor, occurred_atfrom_phase and to_phase are { id, name }. card is the case in its current state, including tags, description, contacts, and records.
card.assignedcard, added, removed, actor, assignment, occurred_atadded and removed are lists of { id, name }. assignment is { automatic, rule_name }; rule_name is null for manual changes and the fallback pool.

Shared fields on case events:

FieldDescription
actorWho made the change: { "type": "member", "id", "name" }, or { "type": "system", "id": null, "name": null } for automatic assignment. Member names can be null.
occurred_atISO 8601 time of the change. Use it, not the delivery time, to order events.
card.updated_atLast update of the case at the time the payload was built.

Contact events

contact.created is sent once for every new contact, from any of these sources:

  • Web forms
  • WhatsApp conversations, as soon as the contact is created during the conversation
  • Inbox Create contact
  • Marketing opt-in
  • API sends (for example starting a WhatsApp conversation)
  • Manual creation in the app

CSV import does not send it. Test contacts never send it.

Case event delivery

Case events (card.phase_changed, card.assigned) follow the same envelope, headers, signing, retries and auto-pause as every other event.

  • Test cases never send case events.
  • Creating a case does not send card.assigned for its creator.
  • Ordering: deliveries run in parallel, so events can arrive out of order. Order them by occurred_at (and card.updated_at), and ignore an event older than the one you already applied.
  • Redeliver works for case events. The payload is rebuilt with the current state of the case and the original from_phase/to_phase or added/removed.

Intake bot filter on case events

A case's origin Intake Bot is the bot of its earliest linked submission. An endpoint with intake_bot_ids receives a case event only when that bot is listed. Cases without an origin bot (created by hand) reach only endpoints without a filter.

Intake bot filter

Create or update an endpoint with intake_bot_ids to receive events only from those bots (for case events, see the origin bot rule above). 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;
    case 'card.phase_changed':
      handlePhaseChange(event.data.card, event.data.from_phase, event.data.to_phase);
      break;
    case 'card.assigned':
      handleAssignment(event.data.card, event.data.added, event.data.removed);
      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