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.
How It Works
- Register a webhook endpoint URL via the API or Workspace Settings → Webhooks
- Select which events you want to receive
- 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 new submission has been completed (via UI or API) |
submission.updated | A submission's data or status has been updated |
contact.created | A new contact has been created |
contact.updated | An existing contact has been updated |
intake-bot.status-changed | An intake bot's status changed (e.g., ACTIVE → PAUSED) |
Payload Format
Every webhook delivery includes a JSON payload with this structure:
{
"id": "evt_clxyz...",
"type": "submission.completed",
"created_at": "2026-02-23T10:00:00Z",
"data": {
"submission": {
"id": "clxyz...",
"intake_bot_id": "clxyz...",
"status": "COMPLETED",
"data": {
"full_name": "Jane Doe",
"email": "jane@example.com"
},
"created_at": "2026-02-23T10:00:00Z"
}
}
}The data field contents vary by event type. For submission.* events it contains a submission object; for contact.* events it contains a contact object.
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 Behavior
If your endpoint returns a non-2xx status code or fails to respond within 10 seconds, ioZen retries delivery:
- Retries: Up to 5 attempts with exponential backoff
- Concurrency: Max 3 concurrent deliveries per workspace to prevent overwhelming your server
- Timeout: Your endpoint must respond within 10 seconds
Auto-Pause
After 10 consecutive delivery failures, the webhook endpoint is automatically paused to prevent further wasted delivery attempts. When paused:
- No new events are sent to the endpoint
- The endpoint status shows as "paused" in the dashboard and API
- You can re-enable it from Workspace Settings → Webhooks
Fix the underlying issue (server down, URL changed, etc.) before re-enabling.
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
Register a webhook endpoint pointing to a request inspection tool like webhook.site or ngrok during development.
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');
}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