# Webhooks

> Events, payloads, signature check, retries, target rules.

## 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](#managing-webhooks-through-the-api)). 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

| 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

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

## Event payloads

Each event has `type` and `occurred_at` (UTC), plus the keys of its type. `subscriber` is the full [subscriber object](https://docs.uptools.io/echo/developer-guide/subscribers/#the-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`**

```json
{
  "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": "lilla.farkas@example.com",
        "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:

```json
{"type":"subscriber.added_to_group","occurred_at":"2026-10-22T05:53:30.112623Z","subscriber":{"id":"d5f0b4b1-b59d-40eb-b2e0-74857893a055","email":"lilla.farkas@example.com","...":"..."},"group":{"id":"257e83a2-d55e-4a8c-b9a4-b3e939c151cc","name":"Webshop vásárlók"}}
```

**`subscriber.automation_triggered`** and **`subscriber.automation_completed`** add the workflow:

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

```json
{"type":"subscriber.deleted","occurred_at":"2026-10-22T05:59:03Z","subscriber":{"id":"8273ff7d-cb69-4a72-83e6-dbebbec33115","email":"bence.kovacs@example.com","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`**

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

```json
{"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 (`subscriber@example.com`, "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

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

```js
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
<?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)**

```python
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:

```bash
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":"lilla.farkas@example.com","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

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

## Target rules

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.

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

```bash
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}'
```

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

```bash
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}'
```

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

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:

```json
{
  "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": "lilla.farkas@example.com", "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.
