Skip to content
Documentation for: Echo
HUuptools.io
Markdown

Webhooks

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

POST /hooks/echo HTTP/1.1
Host: crm.example.com
Content-Type: application/json
User-Agent: upmail-webhook
X-Upmail-Signature: sha256=<64 hex characters>
X-Upmail-Delivery: 3b0f6a52-8c1e-4d7a-9f20-6e4b1c2d3a45
X-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.

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",
"email": "[email protected]",
"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.

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; // seconds
const 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 hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["ECHO_WEBHOOK_SECRET"].encode("utf-8")
MAX_AGE = 300 # seconds
seen = 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:

Terminal window
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.

  • 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, and resent_from names 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-Delivery and 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.

Echo only calls targets that pass these rules:

  • The URL starts with http:// or https:// 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.

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

Terminal window
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.)

Terminal window
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.

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 in X-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.