# 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.

> **Before you start:** You need the Pro plan, and you must be the workspace owner or an admin. Your URL must use HTTPS and be reachable from the internet.

## 1. Open webhooks

In the sidebar, go to “Integrations” and open the “Webhooks” tab.

![The webhooks tab, before you create the first one.](https://iozen.ai/docs/shots/en/webhooks-tab-desktop.webp)

## 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:

```json
{
  "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:

```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](https://app.iozen.ai/docs/webhooks). To create and manage endpoints from your own code, use the [ioZen API](https://iozen.ai/docs/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.
