Outgoing webhooks
With outgoing webhooks your systems learn right away what happens in Zoan Automation, without polling the API every few minutes:
- Close the loop with the ERP: when the execution that registered an invoice finishes, the ERP receives the notice and updates its status.
- Chain processes: when a flow case finishes, your backend starts the next step in another system.
- Take tasks to where people work: every new human task reaches your internal tool, a chat channel or a board.
- Alert whoever validates documents: when an IDP document needs review, your system knows at that moment.
Each notice is a signed JSON POST to a URL of yours. If your system does not respond, Zoan Cloud retries with increasing waits, and every delivery is kept in a log you can check and resend from.
Events
Section titled “Events”| Event | When it is sent | What data carries |
|---|---|---|
execution.finished | A robot execution finished: completed, failed, stopped or cancelled. Designer test runs do not notify. | execution: the same object as GET /executions/{id} without inputs or outputs (id, process, status, machine, error codes, dates, flow case if any). |
case.finished | A Zoan Flow case finished: completed or cancelled. | case: id, flow, version, status, closing reason and dates. |
human_task.created | A human task was created (by a robot, a flow, an assistant or the API). | task: id, title, priority, source, who it is addressed to, due date and case or queue item. |
human_task.decided | Someone decided a human task (expirations and cancellations do not notify). | task: id, title, decision, who and when. |
document.review_required | An IDP document was extracted and is waiting for a person to validate it. | document: id, reference, file, type, project and review reason (fields, policy or qa-sample). |
Notices carry identifiers and statuses, not business data: no execution inputs or outputs, no case variables, no form fields. With the id, your system asks the API for whatever it needs, with its key’s permissions.
Who can subscribe to each event
Section titled “Who can subscribe to each event”A webhook never reveals more than its creator can see. That is why, besides the webhook:manage permission, whoever creates the subscription needs:
| Event | Additional permission |
|---|---|
execution.finished | execution:list on the whole environment (the root and every folder). |
document.review_required | document:list on the whole environment. |
case.finished | orchestration:list. |
human_task.created, human_task.decided | human_task:list and human_task:view_all (tasks belong to whoever they are addressed to: notifying about all of them requires seeing all of them). |
If one is missing, the response is 403 WEBHOOK_EVENT_FORBIDDEN with what is needed. Viewing subscriptions and their log requires webhook:list. Owner and Admin have both permissions; for an integration, add them to a custom role.
Create a webhook
Section titled “Create a webhook”From the portal
Section titled “From the portal”- Go to the environment’s Centro › Recursos › Avisos salientes (Center › Resources › Outgoing webhooks).
- Click Crear aviso (Create webhook), type a name, the URL that receives the notices and tick the events.
- Copy the signing secret shown on creation: it is shown only once. Store it in the secret manager of the receiving system.
- Enviar prueba (Send test) delivers a
webhook.pingnotice signed exactly like the others: that way you check the signature before waiting for a real event.
Through the API
Section titled “Through the API”curl -s -X POST "$ZOAN_API/environments/$ENV/webhooks" \ -H "Authorization: Bearer $ZOAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "ERP — invoice status", "url": "https://erp.example.com/zoan/webhooks", "events": ["execution.finished", "case.finished"] }'{ "webhook": { "id": "b7e2c1d4-58a3-4f0b-9c61-2e7d4a9f0b13", "name": "ERP — invoice status", "url": "https://erp.example.com/zoan/webhooks", "events": ["execution.finished", "case.finished"], "status": "active", "disabled_reason": null, "secret_last_four": "Q9xZ", "previous_secret_valid_until": null, "consecutive_failures": 0, "created_at": "2026-10-16T15:20:11Z", "updated_at": "2026-10-16T15:20:11Z" }, "secret": "whsec_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxQ9xZ"}The other routes (list, change, enable or disable, rotate the secret, test, delete and check deliveries) are in the API reference.
URL requirements
Section titled “URL requirements”So that nobody can use webhooks to reach internal networks, the URL must:
- start with
https://(notices only travel encrypted); - point to a public internet server:
localhost, internal names (.local,.internal,.svc…) and private, loopback, link-local or reserved addresses are not accepted; - not carry a username or password.
The check is repeated on every delivery: if your server’s name starts resolving to a private address, the notice is not sent. Zoan Cloud does not follow redirects (a 3xx counts as a failure): use the final URL.
What your system receives
Section titled “What your system receives”POST /zoan/webhooks HTTP/1.1Host: erp.example.comContent-Type: application/jsonUser-Agent: ZoanCloud-Webhooks/1.0Zoan-Webhook-Id: 3f1c8e52-9a7d-4b06-8e21-5c0d9b7a4f68Zoan-Webhook-Event: execution.finishedZoan-Webhook-Attempt: 1Zoan-Webhook-Timestamp: 1760628011Zoan-Webhook-Signature: v1=5d0b0c8e9f4a…
{ "id": "3f1c8e52-9a7d-4b06-8e21-5c0d9b7a4f68", "type": "execution.finished", "created_at": "2026-10-16T15:20:11.482Z", "environment_id": "7c1e4a52-3b9d-4f0e-9a61-2d8f5b0c9e11", "data": { "execution": { "id": "e4a90b17-2c3d-4f58-b6e1-0d7c9a2f5b34", "process_id": "7d1f3c2a-0000-4000-8000-000000000001", "process_name": "Register invoices", "status": "completed", "source": "manual", "agent_id": null, "case_id": null, "exit_code": 0, "error_message": null, "failure_code": null, "created_at": "2026-10-16T15:18:02.110Z", "started_at": "2026-10-16T15:18:04.031Z", "finished_at": "2026-10-16T15:20:10.977Z" } }}| Header | Purpose |
|---|---|
Zoan-Webhook-Id | Event id. It is the same on every retry: store it so you do not process the same notice twice. |
Zoan-Webhook-Event | Event type (same as type in the body). |
Zoan-Webhook-Attempt | Attempt number (1, 2, …). |
Zoan-Webhook-Timestamp | Time of sending, in Unix seconds. It is part of the signature. |
Zoan-Webhook-Signature | Signature: v1=<hex>. During a rotation it carries two, separated by a comma. |
Respond with a 2xx code in less than 10 seconds. If your system needs longer to process, respond first and process afterwards (for example, by queuing the notice). Any other response, or none, is retried.
Verify the signature
Section titled “Verify the signature”The signature is an HMAC-SHA256 with the subscription’s secret over the text "<timestamp>.<body>", in hexadecimal. To verify it:
- Take the raw body exactly as it arrived (the bytes, before parsing it as JSON: if you serialize it again, the signature will not match).
- Compute
HMAC-SHA256(secret, timestamp + "." + body)in hexadecimal. - Compare it, in constant time, with each
v1=value inZoan-Webhook-Signature: one match is enough. - Reject the notice if
Zoan-Webhook-Timestampis more than 5 minutes away from your clock (this stops someone from replaying an old notice).
Node.js (Express)
Section titled “Node.js (Express)”import crypto from 'node:crypto';import express from 'express';
const SECRET = process.env.ZOAN_WEBHOOK_SECRET; // whsec_…const app = express();
// The raw body: the signature is computed over the received bytes.app.post('/zoan/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const timestamp = req.get('Zoan-Webhook-Timestamp') ?? ''; const header = req.get('Zoan-Webhook-Signature') ?? ''; const body = req.body.toString('utf8');
const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > 300) return res.status(400).send('Expired notice');
const expected = crypto.createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest(); const valid = header.split(',').map((p) => p.trim()).filter((p) => p.startsWith('v1=')).some((p) => { const got = Buffer.from(p.slice(3), 'hex'); return got.length === expected.length && crypto.timingSafeEqual(got, expected); }); if (!valid) return res.status(401).send('Invalid signature');
const event = JSON.parse(body); // Idempotency: if you already processed event.id, respond 200 and do nothing else. res.sendStatus(204); handle(event); // after responding});Python (Flask)
Section titled “Python (Flask)”import hashlib, hmac, os, timefrom flask import Flask, request, abort
SECRET = os.environ["ZOAN_WEBHOOK_SECRET"].encode() # whsec_…app = Flask(__name__)
@app.post("/zoan/webhooks")def webhooks(): timestamp = request.headers.get("Zoan-Webhook-Timestamp", "") header = request.headers.get("Zoan-Webhook-Signature", "") body = request.get_data() # raw bytes
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: abort(400) expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest() signatures = [p.strip()[3:] for p in header.split(",") if p.strip().startswith("v1=")] if not any(hmac.compare_digest(s, expected) for s in signatures): abort(401)
event = request.get_json() # Idempotency: store event["id"] and do not process it twice. return "", 204C# (ASP.NET Core)
Section titled “C# (ASP.NET Core)”app.MapPost("/zoan/webhooks", async (HttpRequest req) =>{ var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("ZOAN_WEBHOOK_SECRET")!); using var reader = new StreamReader(req.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(); var timestamp = req.Headers["Zoan-Webhook-Timestamp"].ToString(); var header = req.Headers["Zoan-Webhook-Signature"].ToString();
if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return Results.BadRequest();
using var hmac = new HMACSHA256(secret); var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}")); var valid = header.Split(',').Select(p => p.Trim()).Where(p => p.StartsWith("v1=")) .Any(p => CryptographicOperations.FixedTimeEquals(Convert.FromHexString(p[3..]), expected)); if (!valid) return Results.Unauthorized();
// Idempotency: use the event's "id" field. return Results.NoContent();});Retries, disabling and log
Section titled “Retries, disabling and log”- Retries: if a delivery fails (no response, network error, a code other than 2xx or a redirect), it is retried up to 8 times with increasing waits: about 30 seconds, 2 minutes, 8 minutes, 32 minutes, 2 hours and then every 6 hours. Every attempt carries the same
Zoan-Webhook-Id. - Automatic disabling: after 10 failed deliveries in a row (each with its attempts exhausted), the webhook disables itself and administrators get an email. A successful delivery resets the count. When your system responds again, enable it again and resend the failed deliveries from the log.
- Delivery log: each webhook keeps its deliveries from the last 30 days with their status (pending, delivering, delivered, failed or skipped), attempts, HTTP code, error and duration. A skipped delivery is one that found the webhook disabled.
- Order: notices are not guaranteed to arrive in order (a retry may arrive after a newer notice). Use the body’s
created_atand the resource’s status to decide.
Rotate the secret
Section titled “Rotate the secret”Rotating generates a new secret, shown only once. For 24 hours each notice carries two signatures: one with the new secret and one with the previous one. That way you change the secret in your system without losing notices: update it within that window and, once it is over, only the new signature remains.
Rotate whenever the secret may have been exposed and, as good practice, periodically.
Good practices
Section titled “Good practices”- Always verify the signature and the timestamp’s age before trusting the notice.
- Respond quickly (2xx) and process afterwards; a slow receiver piles up retries.
- Process each
Zoan-Webhook-Idonly once: retries and re-enabling can repeat a notice. - Query the API for details: the notice says what changed; you read the data with an API key that has the minimum the integration needs.
- One webhook per receiving system: if one fails or has to be disabled, the others keep going.
Next steps
Section titled “Next steps”- Zoan Cloud API — keys, permissions and every route, including the webhook ones.
- Users and roles — create a custom role with
webhook:manageand the event permissions. - Webhook triggers — the opposite direction: another system starting a process or a flow in Zoan Cloud.