# Webhookok

> Események, payloadok, aláírás-ellenőrzés, újrapróbálás, célcím-szabályok.

## Hogyan működnek a webhookok

A webhook a te HTTPS (vagy HTTP) végpontod, amelyet az Echo POST-kéréssel hív meg, amikor a feliratkozott események bekövetkeznek. Webhookot a felületen az **Integrációk**, **Webhookok** úton, vagy az API-n át ([lent](#webhookok-kezelése-az-api-n-át)) hozhatsz létre. Minden webhooknak saját aláírókulcsa van.

- Az Echo csak akkor gyűjti az eseményeket, ha legalább egy **bekapcsolt** webhook figyeli őket. Azok az események, amelyek a webhook kikapcsolt állapotában vagy a létrehozása előtt történtek, soha nem mennek ki.
- Az Echo néhány másodpercenként nézi az új eseményeket. A kézbesítések sorrendje felcserélődhet (egy újrapróbált egy újabb után érkezik), ezért az eseményeket az `occurred_at` szerint rendezd, ne az érkezés szerint.
- A tartalom a kézbesítés előkészítésekor, az akkori adatokból készül. Az időközben törölt feliratkozó kimarad az eseményből.
- Egy munkaterületnek legfeljebb 20 webhookja lehet.

## Események

| Esemény | Mikor megy | Kulcsok az eseményben |
|---|---|---|
| `subscriber.created` | Bármilyen módon új feliratkozó kerül be (felület, import, API, feliratkozás). | `subscriber` |
| `subscriber.updated` | Megváltozik a feliratkozó e-mail-címe vagy mezőértéke. | `subscriber` |
| `subscriber.active` | Az állapot `active` lesz (például dupla megerősítés után). | `subscriber` |
| `subscriber.unsubscribed` | Az állapot `unsubscribed` lesz. | `subscriber` |
| `subscriber.bounced` | Az állapot `bounced` lesz. | `subscriber` |
| `subscriber.spam_reported` | Az állapot `junk` lesz (spamjelentés). | `subscriber` |
| `subscriber.deleted` | A feliratkozót törlik vagy elfelejtik. Csak kötegelve. | `subscriber` (rövid forma) |
| `subscriber.added_to_group` | A feliratkozó bekerül egy csoportba. | `subscriber`, `group` |
| `subscriber.removed_from_group` | A feliratkozót kiveszik egy csoportból (a csoport vagy a feliratkozó törlésekor nem). | `subscriber`, `group` |
| `subscriber.automation_triggered` | A feliratkozó belép egy munkafolyamatba. | `subscriber`, `automation` |
| `subscriber.automation_completed` | A feliratkozó végigér egy munkafolyamaton. | `subscriber`, `automation` |
| `campaign.sent` | Egy kampány kiküldése befejeződik (a munkafolyamatok levelei nem). | `campaign` |
| `campaign.open` | Egy kampánylevél minden megnyitása. Csak kötegelve. | `subscriber`, `campaign`, `unique` |
| `campaign.click` | Minden kattintás egy kampánylevélben. Csak kötegelve. | `subscriber`, `campaign`, `unique`, `url` |

A „csak kötegelve” küldhető eseményeket csak akkor választhatod, ha a webhooknál be van kapcsolva a `batchable`.

## Az Echo által küldött kérés

```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":[...]}
```

| Fejléc | Tartalom |
|---|---|
| `X-Upmail-Signature` | `sha256=` és a nyers kéréstörzs hexadecimális HMAC-SHA256 értéke, a webhook aláírókulcsával. |
| `X-Upmail-Delivery` | A kézbesítés azonosítója, ugyanaz, mint a törzsben a `delivery_id`. |
| `X-Upmail-Timestamp` | A `sent_at` Unix-másodpercben. |

| Törzskulcs | Tartalom |
|---|---|
| `delivery_id` | A kézbesítés azonosítója. Az automatikus újrapróbálásoknál ugyanaz marad. A felületről indított kézi újraküldés új kézbesítés, saját `delivery_id` értékkel. |
| `webhook_id` | A webhook azonosítója. |
| `sent_at` | Mikor ment ki ez a kísérlet (UTC, egész másodperc). Minden kísérletnek saját értéke van. |
| `test` | Tesztkézbesítésnél (**Teszt küldése** a felületen) `true`, egyébként hiányzik. |
| `resent_from` | Kézi újraküldésnél annak a kézbesítésnek a `delivery_id` értéke, amelyet újraküld. Egyébként hiányzik. |
| `events` | Az események: egy, kötegelt webhooknál legfeljebb 100. |

## Az események tartalma

Minden eseményben van `type` és `occurred_at` (UTC), plusz a típusához tartozó kulcsok. A `subscriber` az API teljes [feliratkozó objektuma](https://docs.uptools.io/hu/echo/developer-guide/subscribers/#a-feliratkozó-objektum). Az üzenet minden időpontja, a feliratkozóé is, UTC-ben érkezik (RFC 3339, `Z` végződéssel), ugyanúgy, mint az API válaszaiban.

**`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"
      }
    }
  ]
}
```

A **`subscriber.added_to_group`** és a **`subscriber.removed_from_group`** a csoportot is viszi:

```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"}}
```

A **`subscriber.automation_triggered`** és a **`subscriber.automation_completed`** a munkafolyamatot is viszi:

```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)"}}
```

A **`subscriber.deleted`** rövid feliratkozót visz: az `id`, `email` és `status` értéket a törlés előtti állapot szerint. GDPR szerinti elfelejtés után csak az azonosítót és egy jelzőt:

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

Ha `"forgotten": true` érkezik, töröld a személy adatait a saját rendszeredben is.

**`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"}}
```

A **`campaign.open`** és a **`campaign.click`** minden megnyitásról és kattintásról megy. A `unique` értéke `true` az adott feliratkozó első megnyitásánál (vagy első kattintásánál) abban a levélben. Az `url` a link úgy, ahogy a kampányban tárolva van, kitöltetlen címkékkel:

```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"}
```

A **tesztkézbesítések** `"test": true` jelzést és egy eseményt visznek, a webhook első eseménytípusával, mintaadatokkal (`subscriber@example.com`, „Sample group”, „Sample workflow”, „Sample campaign”).

A tartalmat rugalmasan olvasd: a már nem létező hivatkozott objektum kimarad, és új kulcsok is megjelenhetnek.

## Az aláírás ellenőrzése

Számold ki a HMAC-SHA256 értéket a **nyers kéréstörzsön** (a pontos bájtokon, JSON-feldolgozás előtt), kulcsként az aláírókulccsal, és a hexadecimális alakját hasonlítsd össze az `X-Upmail-Signature` fejlécben a `sha256=` utáni résszel. A kulcs a felületen mutatott szöveg pontosan (UTF-8 bájtjai); ne dekódold hexából vagy base64-ből. Állandó idejű összehasonlítást használj.

A visszajátszott kérések ellen azt is ellenőrizd, hogy az `X-Upmail-Timestamp` közel van-e a saját órádhoz. Minden kísérletet friss időbélyeggel ír alá újra az Echo, így néhány perces ablak elég. A példák 5 percet használnak; válaszd azt, ami nálad beválik.

**JavaScript (Node.js, keretrendszer nélkül)**

```js
import crypto from "node:crypto";
import http from "node:http";

const SECRET = process.env.ECHO_WEBHOOK_SECRET;
const MAX_AGE = 300; // másodperc
const seen = new Set(); // élesben: adatbázistábla egyedi kulccsal

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);
```

Express esetén ezen az útvonalon a törzset az `express.raw({ type: "application/json" })` olvassa be, hogy a nyers bájtokat ellenőrizd, ne egy újra sorosított objektumot.

**PHP**

```php
<?php
$secret = getenv('ECHO_WEBHOOK_SECRET');
$maxAge = 300; // másodperc

$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;
}

// Szűrd a duplikátumokat a $delivery alapján (például egyedi kulccsal az adatbázisban), aztán:
$body = json_decode($raw, true);
foreach ($body['events'] as $event) {
    error_log($event['type'] . ' ' . ($event['subscriber']['email'] ?? ''));
}
http_response_code(200);
```

**Python (standard könyvtár)**

```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  # másodperc
seen = set()  # élesben: adatbázistábla egyedi kulccsal

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()
```

Flask esetén a nyers törzs a `request.get_data()`.

**curl: aláírt tesztkérés a saját végpontodra**

Ez ugyanúgy állítja össze a kérést, mint az Echo, így a fogadódat a bekötés előtt kipróbálhatod:

```bash
export ECHO_WEBHOOK_SECRET=<az aláírókulcs>
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"
```

A fenti három fogadó erre a kérésre `200` választ ad, az ismétlésre szintén `200`-at (másodszor nem dolgozza fel), rossz kulcsnál pedig `401`-et.

## Válasz, újrapróbálás és duplikátumszűrés

- Válaszolj bármilyen **2xx** kóddal **10 másodpercen** belül. Az Echo legfeljebb 64 KB-ot olvas a válaszodból, és az elejét a kézbesítési naplóban tartja; a tartalma nem számít.
- Minden más (más kód, időtúllépés, kapcsolódási hiba, átirányítás) sikertelen kísérletnek számít. Az Echo **10, 100 és 1000 másodperc** múlva újra próbálkozik: összesen négy kísérlet. A negyedik után a kézbesítés sikertelen.
- A sikertelen kézbesítés a felületről újraküldhető (**Újraküldés** a kézbesítés ablakában). Az újraküldés új kézbesítés: saját `delivery_id` értéke van, a `resent_from` pedig megnevezi az eredetit, amely változatlan marad.
- A kézbesítés **legalább egyszeri**: ugyanaz a kézbesítés többször is megérkezhet (például ha a 2xx válaszod elveszett). Tárold az `X-Upmail-Delivery` értékét, és a már feldolgozott azonosítókat hagyd ki. A kézi **Újraküldés** új azonosítót kap, így a rendszered feldolgozza.
- A lassú munkát a válasz után végezd (tedd az eseményeket egy sorba), hogy beleférj a 10 másodpercbe.
- A webhook kikapcsolása a várakozó kézbesítéseit sikertelenné teszi. A napló 30 napig őrzi a kézbesítéseket.

## Célcím-szabályok

Az Echo csak olyan célt hív, amely megfelel ezeknek:

- Az URL `http://` vagy `https://` kezdetű, és legfeljebb 2048 karakter. Élesben HTTPS-t használj.
- A port 80, 443, vagy 1024 fölötti.
- A hosztnévnek csak **nyilvános** internetes címekre szabad mutatnia. A privát, loopback, link-local, szolgáltatói NAT, multicast, dokumentációs és más fenntartott tartományokat az Echo elutasítja, akkor is, ha egy nyilvános név ilyenre oldódik fel. Az elutasított cím azonnal sikertelenné teszi a kézbesítést, újrapróbálás nélkül.
- Nincs proxy és nincs átirányítás: a 3xx válasz sikertelen kísérletnek számít.

A portot és az URL formáját mentéskor, a címet minden kézbesítéskor ellenőrzi az Echo.

## Webhookok kezelése az API-n át

Olvasáshoz Beállítások: megtekintés, módosításhoz Beállítások: kezelés jog kell. Beállítások: kezelés jog nélkül a token az URL-nek csak az eredetét látja.

| Metódus és útvonal | Mit csinál |
|---|---|
| `GET /webhooks` | Listázza a webhookokat (`limit`, `cursor`). |
| `POST /webhooks` | Webhookot hoz létre. Az aláírókulcs csak ebben a válaszban jelenik meg. |
| `GET /webhooks/{id}` | Egy webhook. |
| `PUT /webhooks/{id}` | A megadott kulcsokat módosítja. |
| `DELETE /webhooks/{id}` | Törli a kézbesítési naplójával együtt. |

Törzskulcsok: `name` (legfeljebb 100 karakter), `url`, `events` (legalább egy), `enabled` (alapból `true`), `batchable` (alapból `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>"}}
```

(Az aláírókulcs helyén példaérték áll.)

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

A `last_delivery` a legutóbbi kézbesítés `status` (`pending`, `delivered`, `failed`), `status_code` és időpont értéke, vagy `null`. Az eseményeket az Echo rögzített sorrendben tárolja, bármilyen sorrendben küldöd őket.

| Hiba | Mikor |
|---|---|
| `422`, `fields.url`: `Enter an http:// or https:// URL.` | Nem http(s) URL. |
| `422`, `fields.url`: `Use port 80, 443 or a port above 1024.` | Más port. |
| `422`, `fields.events` és `fields.events.<n>` | Ismeretlen eseménynév. |
| `422`, `fields.batchable` | Csak kötegelve küldhető esemény `"batchable": true` nélkül. |
| `409 webhook_limit` | A munkaterületnek már 20 webhookja van. |

Az aláírókulcs forgatása, a tesztkézbesítés és a sikertelen kézbesítések újraküldése csak a felületen érhető el. Kikapcsolt webhooknak a felület nem küld tesztet.

## Munkafolyamatok webhook-lépései

Egy automatizálás **webhook-lépéssel** is hívhatja a végpontodat. Ezek a kérések ugyanazokat a fejléceket és ugyanazt az aláírási módot használják, de:

- A munkaterület **workflow-kulcsa** írja alá őket (felület: **Integrációk**, **Webhookok**, **Workflow-kulcs**), nem egy webhook kulcsa.
- A törzs egyetlen objektum:

```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"
}
```

- A `delivery` (az `X-Upmail-Delivery` fejlécben is) a futást és a lépést azonosítja: erre szűrd a duplikátumokat.
- A nem 2xx választ kapó lépést az Echo összesen legfeljebb ötször próbálja, rövid, növekvő várakozással. Tiltott célcímet vagy portot nem próbál újra.
- A lépés kimarad, ha a feliratkozó a futás idejére már nem aktív (kivéve közvetlenül a munkafolyamat saját leiratkoztató lépése után).

Ugyanazok a célcím-szabályok érvényesek.
