Guide · Integrations and API
Get events from ioZen with webhooks
A webhook tells your system when something happens in ioZen, like a completed submission, a new contact, or a case that changes phase or owner. Here's how to create one, test it and verify its signature.
A webhook is a URL of yours that ioZen calls when something happens in your workspace. Say someone finishes your carpentry shop's Intake Bot: your system gets the answers right away, without asking ioZen over and over.
1. Open webhooks
In the sidebar, go to “Integrations” and open the “Webhooks” tab.

2. Create the endpoint
Tap “Create Webhook” and fill in:
- “Endpoint URL”: the address on your server that receives the events. It starts with
https://. - “Description”: optional, so your team knows what it's for.
- “Events”: check at least one event.
- “Intake bots”: leave it empty to get events from all your bots, or check only the ones you care about.
Tap “Create Webhook” to save it.
3. Save the secret
ioZen shows you the “Signing Secret”, which starts with whsec_. Copy it and store it on your server: you won't see it again. It's how you check that each call really comes from ioZen.
4. Send a test
In the endpoint's menu, tap “Send Test”. On a phone, the button is on the endpoint's card. ioZen immediately sends one of the events you picked, with "is_test": true inside data, and tells you whether your server answered correctly.
The events
| Event | In the app | When it arrives |
|---|---|---|
submission.completed |
“Submission Completed” | Someone finishes an Intake Bot. |
contact.created |
“Contact Created” | A new contact is created. |
card.phase_changed |
“Case Phase Changed” | A case moves to another phase on its Process Board. |
card.assigned |
“Case Assigned” | A case's owners change, by hand or through an assignment rule. |
Limit an endpoint to certain bots
If you check bots under “Intake bots”, the endpoint only gets events that come from those bots. That way a clinic can send appointments to one system and price questions to another.
Two cases are worth knowing. A contact someone on your team creates by hand doesn't come from any bot, so it reaches every endpoint subscribed to “Contact Created”. And a case that didn't start in a bot only reaches endpoints with no bots checked.
What your server receives
Each event arrives as a POST with this JSON body:
{
"id": "evt_8f2c41d07a9b4e3f9a6d1c5b7e0f2a34",
"type": "submission.completed",
"api_version": "2026-08-27",
"created_at": "2026-11-06T15:04:05.000Z",
"data": {}
}data holds what happened and depends on the event. The full reference shows each one.
These headers come with the body:
| Header | What it holds |
|---|---|
X-IoZen-Event-Type |
The event type, same as type. |
X-IoZen-Event-Id |
The event id, same as id. |
X-IoZen-Timestamp |
When it was signed, in Unix seconds. |
X-IoZen-Signature |
The signature: sha256= followed by the hex HMAC-SHA256. |
Verify the signature
ioZen signs every call with your secret. The signature is the HMAC-SHA256 of the text timestamp.body: the X-IoZen-Timestamp value, a dot, and the body exactly as it arrived. The key is the whole secret, whsec_ included.
Compute it from the raw body, before you parse the JSON. If you parse it and write it back out, the signature won't match. In Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody: the body exactly as received (text). secret: your whsec_...
export function verifyIozenWebhook(rawBody, headers, secret) {
const timestamp = headers['x-iozen-timestamp']
const signature = headers['x-iozen-signature'] ?? ''
const expected =
'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(signature)
if (a.length !== b.length || !timingSafeEqual(a, b)) return false
// Reject stale calls (here, older than 5 minutes)
return Math.abs(Date.now() / 1000 - Number(timestamp)) < 300
}Every attempt is signed again with the current time, so retries pass this check.
Deliveries, retries and pausing
Your server has 10 seconds to answer with a 2xx status. Any other response, a redirect, or no answer in time counts as a failure.
If it fails, we retry up to 5 times. A retry carries the same id, so use it to avoid handling the same event twice.
After 10 failed attempts in a row, we pause the endpoint and email the workspace owners and admins. A “Paused” endpoint gets no events. Once your server is back, tap “Resume”.
In the endpoint's menu, “Deliveries” lists recent attempts with their status, HTTP code and how long they took. “Redeliver” sends that event again right away, built from the current data and with a new id; the row is marked “Manual”.
Change or delete an endpoint
In the endpoint's menu, “Edit” changes the events, the description and the bots. The URL can't change: create another endpoint with the new one. “Delete” removes it and stops sending it events.
The full reference
The webhooks reference, with what data holds for each event, is at app.iozen.ai/docs/webhooks. To create and manage endpoints from your own code, use the ioZen API with the “Manage Webhooks” scope.
Frequently asked questions
- Which plan do I need for webhooks?
- Pro or higher. How many endpoints you can have depends on your plan; see Pricing.
- Can a team member create webhooks?
- No. Only the workspace owner or an admin sees the webhooks tab.
- What happens if my server is down?
- We retry each event up to 5 times. After 10 failed attempts in a row, we pause the endpoint and email the workspace owners and admins.
- Can I change an endpoint's URL?
- No. Create a new endpoint with the new URL and delete the old one.