Webhooks
How webhooks work
Section titled “How webhooks work”A webhook is an HTTPS (or HTTP) endpoint of yours that Echo calls with a POST request when the events it subscribed to happen. Webhooks are created in the app under Integrations, Webhooks or through the API (below). Each webhook has its own signing secret.
- Events are collected only while at least one enabled webhook listens to them. Events from a time when the webhook was off, or before it was created, are never sent.
- Echo checks for new events every few seconds. Deliveries can arrive out of order (a retried one after a newer one), so order events by
occurred_at, not by arrival. - The payload is built when the delivery is prepared, from the data as it is then. A subscriber deleted in the meantime is left out of the event.
- A workspace can have at most 20 webhooks.
Events
Section titled “Events”| Event | Sent when | Keys in the event |
|---|---|---|
subscriber.created |
A subscriber is added in any way (app, import, API, sign-up). | subscriber |
subscriber.updated |
A subscriber’s email address or field values change. | subscriber |
subscriber.active |
The status changes to active (for example after a double opt-in confirmation). |
subscriber |
subscriber.unsubscribed |
The status changes to unsubscribed. |
subscriber |
subscriber.bounced |
The status changes to bounced. |
subscriber |
subscriber.spam_reported |
The status changes to junk (spam complaint). |
subscriber |
subscriber.deleted |
A subscriber is deleted or forgotten. Batch only. | subscriber (short form) |
subscriber.added_to_group |
A subscriber joins a group. | subscriber, group |
subscriber.removed_from_group |
A subscriber is taken out of a group (not when the group or the subscriber is deleted). | subscriber, group |
subscriber.automation_triggered |
A subscriber enters a workflow. | subscriber, automation |
subscriber.automation_completed |
A subscriber finishes a workflow. | subscriber, automation |
campaign.sent |
A campaign finishes sending (not workflow emails). | campaign |
campaign.open |
Every open of a campaign email. Batch only. | subscriber, campaign, unique |
campaign.click |
Every click in a campaign email. Batch only. | subscriber, campaign, unique, url |
“Batch only” events can be chosen only when the webhook has batchable on.
The request Echo sends
Section titled “The request Echo sends”POST /hooks/echo HTTP/1.1Host: crm.example.comContent-Type: application/jsonUser-Agent: upmail-webhookX-Upmail-Signature: sha256=<64 hex characters>X-Upmail-Delivery: 3b0f6a52-8c1e-4d7a-9f20-6e4b1c2d3a45X-Upmail-Timestamp: 1792659131
{"delivery_id":"3b0f6a52-8c1e-4d7a-9f20-6e4b1c2d3a45","webhook_id":"8c17fb2c-011a-4058-a958-b56459fb2c4c","sent_at":"2026-10-22T08:52:11Z","events":[...]}| Header | Content |
|---|---|
X-Upmail-Signature |
sha256= and the hex HMAC-SHA256 of the raw request body, keyed with the webhook’s signing secret. |
X-Upmail-Delivery |
The delivery ID, the same as delivery_id in the body. |
X-Upmail-Timestamp |
sent_at as Unix seconds. |
| Body key | Content |
|---|---|
delivery_id |
The delivery’s ID. It stays the same on the automatic retries. A manual resend from the app is a new delivery with its own delivery_id. |
webhook_id |
The webhook’s ID. |
sent_at |
When this attempt was sent (UTC, whole seconds). Each attempt has its own. |
test |
true on a test delivery (Send test in the app); missing otherwise. |
resent_from |
On a manual resend from the app: the delivery_id of the delivery it resends. Missing otherwise. |
events |
The events: one, or up to 100 for a batchable webhook. |
Event payloads
Section titled “Event payloads”Each event has type and occurred_at (UTC), plus the keys of its type. subscriber is the full subscriber object of the API. Every time in a payload, the subscriber’s included, is UTC (RFC 3339 with Z), the same as in the API’s answers.
subscriber.created
{ "delivery_id": "3b0f6a52-8c1e-4d7a-9f20-6e4b1c2d3a45", "webhook_id": "8c17fb2c-011a-4058-a958-b56459fb2c4c", "sent_at": "2026-10-22T08:52:11Z", "events": [ { "type": "subscriber.created", "occurred_at": "2026-10-22T08:52:08.527488Z", "subscriber": { "id": "d5f0b4b1-b59d-40eb-b2e0-74857893a055", "status": "unconfirmed", "source": "api", "fields": {"city": "Szeged", "last_name": "Farkas", "name": "Lilla", "rendelesek_szama": 3}, "groups": [{"id": "257e83a2-d55e-4a8c-b9a4-b3e939c151cc", "name": "Webshop vásárlók"}], "signup_ip": "203.0.113.25", "optin_ip": null, "subscribed_at": "2026-10-22T08:52:08.527488Z", "opted_in_at": null, "unsubscribed_at": null, "timezone": null, "location_country": null, "location_city": null, "sent": 0, "opens": 0, "clicks": 0, "open_rate": 0, "click_rate": 0, "created_at": "2026-10-22T08:52:08.527488Z", "updated_at": "2026-10-22T08:52:08.527488Z" } } ]}subscriber.added_to_group and subscriber.removed_from_group add the group:
{"type":"subscriber.added_to_group","occurred_at":"2026-10-22T05:53:30.112623Z","subscriber":{"id":"d5f0b4b1-b59d-40eb-b2e0-74857893a055","email":"[email protected]","...":"..."},"group":{"id":"257e83a2-d55e-4a8c-b9a4-b3e939c151cc","name":"Webshop vásárlók"}}subscriber.automation_triggered and subscriber.automation_completed add the workflow:
{"type":"subscriber.automation_triggered","occurred_at":"2026-10-21T16:51:00.565674Z","subscriber":{"...":"..."},"automation":{"id":"477323d4-1ba3-4658-b70d-a7d94ceed56b","name":"Welcome email (EN)"}}subscriber.deleted carries a short subscriber: id, email and status as they were before the deletion. After a GDPR forget, only the ID and a flag:
{"type":"subscriber.deleted","occurred_at":"2026-10-22T05:59:03Z","subscriber":{"id":"8273ff7d-cb69-4a72-83e6-dbebbec33115","email":"[email protected]","status":"active"}}{"type":"subscriber.deleted","occurred_at":"2026-10-22T05:59:04Z","subscriber":{"id":"98cc16c4-cb25-4ec6-8dbe-9704c4e198c4","forgotten":true}}When you get "forgotten": true, delete the person’s data on your side too.
campaign.sent
{"type":"campaign.sent","occurred_at":"2026-10-20T08:02:25.333333Z","campaign":{"id":"30bc6133-16ad-4074-b311-204127a80b62","name":"Winter-ready garden (EN)","type":"regular"}}campaign.open and campaign.click are sent for every open and click. unique is true for the first open (or first click) of that email by that subscriber. url is the link as stored in the campaign, with merge tags not filled in:
{"type":"campaign.click","occurred_at":"2026-10-20T08:47:18.826706Z","subscriber":{"...":"..."},"campaign":{"id":"30bc6133-16ad-4074-b311-204127a80b62","name":"Winter-ready garden (EN)","type":"regular"},"unique":true,"url":"https://kertlak.example.com/teli-felkeszites?utm_source=kertlak"}Test deliveries carry "test": true and one event of the webhook’s first event type, with sample data ([email protected], “Sample group”, “Sample workflow”, “Sample campaign”).
Read the payload leniently: a referenced object that no longer exists is left out, and new keys may appear.
Verifying the signature
Section titled “Verifying the signature”Compute HMAC-SHA256 over the raw request body (the exact bytes, before any JSON parsing) with the signing secret as the key, and compare its hex form with the part after sha256= in X-Upmail-Signature. The key is the secret string exactly as shown in the app (its UTF-8 bytes); don’t hex- or base64-decode it. Use a constant-time comparison.
To protect against replayed requests, also check that X-Upmail-Timestamp is close to your clock. Every attempt is signed again with a fresh timestamp, so a window of a few minutes is enough. The examples use 5 minutes; choose what fits you.
JavaScript (Node.js, no framework)
import crypto from "node:crypto";import http from "node:http";
const SECRET = process.env.ECHO_WEBHOOK_SECRET;const MAX_AGE = 300; // secondsconst seen = new Set(); // in production: a database table with a unique key
function verify(rawBody, signature, timestamp) { const prefix = "sha256="; if (typeof signature === "string" && signature.startsWith(prefix)) { const expected = crypto.createHmac("sha256", SECRET).update(rawBody).digest(); const given = Buffer.from(signature.slice(prefix.length), "hex"); const age = Math.abs(Date.now() / 1000 - Number(timestamp)); return given.length === expected.length && crypto.timingSafeEqual(given, expected) && age <= MAX_AGE; } return false;}
http.createServer((req, res) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { const raw = Buffer.concat(chunks); if (verify(raw, req.headers["x-upmail-signature"], req.headers["x-upmail-timestamp"]) === false) { res.writeHead(401).end(); return; } const delivery = req.headers["x-upmail-delivery"]; if (seen.has(delivery) === false) { seen.add(delivery); const body = JSON.parse(raw.toString("utf8")); for (const event of body.events) { console.log(event.type, event.subscriber?.email); } } res.writeHead(200).end(); });}).listen(3000);With Express, read the body with express.raw({ type: "application/json" }) on this route, so you sign the raw bytes and not a re-serialized object.
PHP
<?php$secret = getenv('ECHO_WEBHOOK_SECRET');$maxAge = 300; // seconds
$raw = file_get_contents('php://input');$signature = $_SERVER['HTTP_X_UPMAIL_SIGNATURE'] ?? '';$timestamp = (int) ($_SERVER['HTTP_X_UPMAIL_TIMESTAMP'] ?? 0);$delivery = $_SERVER['HTTP_X_UPMAIL_DELIVERY'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);if (hash_equals($expected, $signature) === false || abs(time() - $timestamp) > $maxAge) { http_response_code(401); exit;}
// Deduplicate on $delivery (for example a unique key in your database), then:$body = json_decode($raw, true);foreach ($body['events'] as $event) { error_log($event['type'] . ' ' . ($event['subscriber']['email'] ?? ''));}http_response_code(200);Python (standard library)
import hashlibimport hmacimport jsonimport osimport timefrom http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["ECHO_WEBHOOK_SECRET"].encode("utf-8")MAX_AGE = 300 # secondsseen = set() # in production: a database table with a unique key
def verify(raw: bytes, signature: str, timestamp: str) -> bool: if not signature or not signature.startswith("sha256="): return False expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature[7:]): return False try: return abs(time.time() - int(timestamp)) <= MAX_AGE except (TypeError, ValueError): return False
class Handler(BaseHTTPRequestHandler): def do_POST(self): raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) if not verify(raw, self.headers.get("X-Upmail-Signature"), self.headers.get("X-Upmail-Timestamp")): self.send_response(401) self.end_headers() return delivery = self.headers.get("X-Upmail-Delivery") if delivery not in seen: seen.add(delivery) for event in json.loads(raw)["events"]: print(event["type"], event.get("subscriber", {}).get("email")) self.send_response(200) self.end_headers()
HTTPServer(("", 3000), Handler).serve_forever()With Flask, use request.get_data() for the raw body.
curl: send a signed test request to your own endpoint
This builds a request the way Echo does, so you can test your receiver before connecting it:
export ECHO_WEBHOOK_SECRET=<the signing secret>BODY='{"delivery_id":"3b0f6a52-8c1e-4d7a-9f20-6e4b1c2d3a45","webhook_id":"8c17fb2c-011a-4058-a958-b56459fb2c4c","sent_at":"2026-10-22T06:15:02Z","events":[{"type":"subscriber.created","occurred_at":"2026-10-22T06:14:58.112623Z","subscriber":{"id":"d5f0b4b1-b59d-40eb-b2e0-74857893a055","email":"[email protected]","status":"active"}}]}'TS=$(date +%s)SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$ECHO_WEBHOOK_SECRET" -hex | sed 's/^.* //')curl -i -X POST "http://localhost:3000/" \ -H "Content-Type: application/json" \ -H "X-Upmail-Signature: sha256=$SIG" \ -H "X-Upmail-Delivery: 3b0f6a52-8c1e-4d7a-9f20-6e4b1c2d3a45" \ -H "X-Upmail-Timestamp: $TS" \ --data "$BODY"All three receivers above answer 200 to this request, 200 again to a repeat (without processing it twice) and 401 when the secret is wrong.
Answering, retries and deduplication
Section titled “Answering, retries and deduplication”- Answer with any 2xx status within 10 seconds. Echo reads at most 64 KB of your answer and keeps the beginning in the delivery log; the content doesn’t matter.
- Anything else (another status, a timeout, a connection error, a redirect) counts as a failed attempt. Echo tries again 10, 100 and 1000 seconds later: four attempts in all. After the fourth, the delivery is failed.
- A failed delivery can be sent again from the app (Resend in the delivery’s window). The resend is a new delivery: it has its own
delivery_id, andresent_fromnames the original, which stays as it was. - Delivery is at least once: the same delivery can arrive more than once (for example when your 2xx answer is lost). Store
X-Upmail-Deliveryand skip IDs you have already processed. A manual Resend has a new ID, so it gets processed. - Process slow work after answering (put the events in a queue), so you stay within 10 seconds.
- Turning a webhook off fails its waiting deliveries. Deliveries are kept in the log for 30 days.
Target rules
Section titled “Target rules”Echo only calls targets that pass these rules:
- The URL starts with
http://orhttps://and is at most 2048 characters. Use HTTPS in production. - The port is 80, 443, or above 1024.
- The host must resolve to public internet addresses only. Private, loopback, link-local, carrier-grade NAT, multicast, documentation and other reserved ranges are refused, also when a public name resolves to them. A refused address fails the delivery at once, without retries.
- No proxy and no redirects: a 3xx answer counts as a failed attempt.
The port and the URL form are checked when you save the webhook; the address is checked at every delivery.
Managing webhooks through the API
Section titled “Managing webhooks through the API”Reading needs Settings: view, changes Settings: manage. Tokens without Settings: manage see only the origin of the URL.
| Method and path | What it does |
|---|---|
GET /webhooks |
Lists webhooks (limit, cursor). |
POST /webhooks |
Creates a webhook. The answer is the only place the secret appears. |
GET /webhooks/{id} |
Reads one. |
PUT /webhooks/{id} |
Changes the given keys. |
DELETE /webhooks/{id} |
Deletes it with its delivery log. |
Body keys: name (up to 100 characters), url, events (at least one), enabled (default true), batchable (default false).
curl -X POST "$ECHO_API/webhooks" \ -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \ -d '{"name":"CRM szinkron","url":"https://crm.example.com/hooks/echo","events":["subscriber.created","subscriber.unsubscribed"],"enabled":false}'{"data":{"id":"27ff1d12-18c0-4897-9c90-330128f2d038","name":"CRM szinkron","url":"https://crm.example.com/hooks/echo","events":["subscriber.created","subscriber.unsubscribed"],"enabled":false,"batchable":false,"created_at":"2026-10-22T08:52:10.281979Z","updated_at":"2026-10-22T08:52:10.281979Z","last_delivery":null,"secret":"<signing-secret>"}}(The secret is replaced with an example value.)
curl -X PUT "$ECHO_API/webhooks/27ff1d12-18c0-4897-9c90-330128f2d038" \ -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \ -d '{"events":["subscriber.created","subscriber.unsubscribed","campaign.click"],"batchable":true}'{"data":{"id":"27ff1d12-18c0-4897-9c90-330128f2d038","name":"CRM szinkron","url":"https://crm.example.com/hooks/echo","events":["subscriber.created","subscriber.unsubscribed","campaign.click"],"enabled":false,"batchable":true,"created_at":"2026-10-22T08:52:10.281979Z","updated_at":"2026-10-22T08:52:10.31774Z","last_delivery":null}}last_delivery is the newest delivery’s status (pending, delivered, failed), status_code and time, or null. Events are stored in a fixed order, whatever order you send them in.
| Error | When |
|---|---|
422, fields.url: Enter an http:// or https:// URL. |
Not an http(s) URL. |
422, fields.url: Use port 80, 443 or a port above 1024. |
Another port. |
422, fields.events and fields.events.<n> |
An unknown event name. |
422, fields.batchable |
A batch-only event without "batchable": true. |
409 webhook_limit |
The workspace has 20 webhooks already. |
The signing secret can be rotated, test deliveries sent and failed deliveries resent only in the app. The app sends no test to a webhook that is turned off.
Workflow webhook steps
Section titled “Workflow webhook steps”An automation workflow can call your endpoint with a webhook step. These requests use the same headers and the same signature scheme, but:
- They are signed with the workspace’s workflow signing key (app: Integrations, Webhooks, Workflow signing key), not with a webhook’s secret.
- The body is one object:
{ "event": "automation.webhook", "delivery": "5d1c9b7e-2f4a-4c3b-8e6d-1a2b3c4d5e6f:3", "automation": {"id": "71a06a41-7c5f-4ab7-b865-b33899fa7343", "name": "Üdvözlő sorozat (HU)"}, "step": "w1", "subscriber": {"id": "d5f0b4b1-b59d-40eb-b2e0-74857893a055", "email": "[email protected]", "status": "active", "fields": {"name": "Lilla", "city": "Szeged"}}, "sent_at": "2026-10-22T06:20:00Z"}delivery(also inX-Upmail-Delivery) identifies the run and step: deduplicate on it.- A step whose answer isn’t 2xx is tried again up to five times in all, with short, growing waits. A blocked target address or port is not retried.
- A step is skipped if the subscriber is no longer active by the time it runs (except right after the workflow’s own unsubscribe step).
The same target rules apply.