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
- Register a webhook endpoint URL via the API or Workspace Settings → Webhooks
- Select which events you want to receive
- Optionally restrict deliveries to specific intake bots with
intake_bot_ids(empty = all bots) - ioZen sends an HTTP POST to your URL when matching events occur
- Your server verifies the signature and processes the event
Event Catalog
| Event | Trigger |
|---|---|
submission.completed | A submission has been completed (via UI or API) |
contact.created | A 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:
| Event | data object | Notes |
|---|---|---|
submission.completed | submission | Includes intake_bot_name and ai_intelligence (null when scoring is off or failed). Formerly named intelligence. |
contact.created | contact | id, 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
| Method | Endpoint | Description |
|---|---|---|
GET | /v1/webhooks | List endpoints |
POST | /v1/webhooks | Register an endpoint (secret returned once). Returns 201. |
GET | /v1/webhooks/:id | Get an endpoint |
PATCH | /v1/webhooks/:id | Partial update: events, description, status (active|paused), intake_bot_ids. URL is immutable (the signing secret is bound to it). |
DELETE | /v1/webhooks/:id | Delete an endpoint |
POST | /v1/webhooks/:id/test | Send a test event (data.is_test: true). Returns { success, status_code, error }. |
GET | /v1/webhooks/:id/deliveries | Cursor-paginated delivery log |
POST | /v1/webhooks/:id/deliveries/:deliveryId/redeliver | Rebuild 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:
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | Always JSON |
X-IoZen-Event-Type | submission.completed | The event type |
X-IoZen-Event-Id | evt_clxyz... | Unique event ID for deduplication |
X-IoZen-Signature | sha256=abc123... | HMAC-SHA256 signature |
X-IoZen-Timestamp | 1740300000 | Unix timestamp when the event was sent |
User-Agent | ioZen-Webhooks/1.0 | Identifies 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:
timestampis the value from theX-IoZen-Timestampheaderpayloadis 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", 200Replay Protection (Recommended)
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:
| Policy | Value |
|---|---|
| Retries | 5 QStash attempts with exponential backoff |
| Concurrency | Max 3 concurrent deliveries per workspace |
| Timeout | 10 seconds |
| Auto-pause | After 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
pausedin 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-Idto handle duplicate deliveries - Log the
X-IoZen-Event-Idfor debugging and support requests - Use HTTPS endpoints — webhook URLs must use HTTPS in production