Recept: příjem webhooků
Cíl: držet CRM v souladu s Poštárnou: nové kontakty založit, odhlášené označit.
Potřebujete: klíč s rolí account_admin (webhooky jsou v modulu settings), e-mail člena firmy pro HTTP Basic, veřejnou adresu https:// vašeho příjemce.
1. Registrace
curl -sS -X POST "https://www.postarna.cz/api/v3/web-hooks" \
-u "$POSTARNA_USER:$POSTARNA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"target_url": "https://crm.example.cz/postarna/webhook",
"event": "new_contact",
"basic_auth": {
"login": "postarna",
"password": "example-webhook-password"
}
}
JSON{
"status": "created",
"meta": [],
"id": 7
}curl -sS -X POST "https://www.postarna.cz/api/v3/web-hooks" \
-u "$POSTARNA_USER:$POSTARNA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"target_url": "https://crm.example.cz/postarna/webhook",
"event": "unsubscribed_contact",
"basic_auth": {
"login": "postarna",
"password": "example-webhook-password"
}
}
JSON{
"status": "created",
"meta": [],
"id": 8
}Heslo basic_auth si vygenerujte náhodně (aspoň 32 znaků) a uložte do proměnné prostředí příjemce.
2. Co přijde
Po založení a odhlášení kontaktu přijdou dvě volání:
curl -sS -X POST "https://www.postarna.cz/api/v1/tenants/$TENANT/contacts" \
-H "Authorization: Bearer $POSTARNA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"email": "eva.horakova@example.cz",
"shopId": "19",
"purposeId": "10",
"consentConfirmed": true,
"firstName": "Eva",
"lastName": "Horáková"
}
JSON{
"id": "1207",
"email": "eva.horakova@example.cz",
"firstName": "Eva",
"lastName": "Horáková",
"status": "subscribed",
"engagement": "N",
"excluded": false,
"shopId": "19",
"suppressionReason": null,
"blacklisted": false,
"blacklistedAt": null,
"state": null,
"createdAt": "2026-10-01T08:00:00.000Z",
"phone": "",
"mobile": "",
"titleBefore": "",
"titleAfter": "",
"salutation": "",
"salutationFormal": "",
"vocativeFirstName": "",
"vocativeLastName": "",
"gender": null,
"language": "cs",
"company": "",
"companyId": "",
"street": "",
"city": "",
"postalCode": "",
"note": "",
"affiliateId": "",
"country": null,
"birthday": null,
"nameDay": null,
"source": "manual",
"consentBasis": "imported",
"statusAt": "2026-10-01T08:00:00.000Z",
"consentAt": "2026-10-01T08:00:00.000Z",
"consentWeak": false,
"softBounces": 0,
"lastSentAt": null,
"lastOpenAt": null,
"lastClickAt": null,
"suppressedAt": null,
"engagementAt": null,
"suppressionNote": null,
"customFields": {},
"updatedAt": "2026-10-01T08:00:00.000Z",
"stats90d": {
"received": 0,
"opened": 0,
"clicked": 0,
"bestHour": null
},
"lists": [],
"recentActivities": [
{
"id": "consent-8",
"type": "status_change",
"at": "2026-10-01T08:00:00.000Z",
"title": "purpose_assigned"
}
],
"orders": [],
"orderTotals": [],
"editedFields": [],
"sources": []
}curl -sS -X POST "https://www.postarna.cz/api/v1/tenants/$TENANT/contacts/1207/unsubscribe" \
-H "Authorization: Bearer $POSTARNA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"note": "Odhlášení z CRM"
}
JSON{
"id": "1207",
"email": "eva.horakova@example.cz",
"firstName": "Eva",
"lastName": "Horáková",
"status": "unsubscribed",
"engagement": "N",
"excluded": false,
"shopId": "19",
"suppressionReason": "unsubscribed",
"blacklisted": false,
"blacklistedAt": null,
"state": null,
"createdAt": "2026-10-01T08:00:00.000Z",
"phone": "",
"mobile": "",
"titleBefore": "",
"titleAfter": "",
"salutation": "",
"salutationFormal": "",
"vocativeFirstName": "",
"vocativeLastName": "",
"gender": null,
"language": "cs",
"company": "",
"companyId": "",
"street": "",
"city": "",
"postalCode": "",
"note": "",
"affiliateId": "",
"country": null,
"birthday": null,
"nameDay": null,
"source": "manual",
"consentBasis": "imported",
"statusAt": "2026-10-01T08:00:00.000Z",
"consentAt": "2026-10-01T08:00:00.000Z",
"consentWeak": false,
"softBounces": 0,
"lastSentAt": null,
"lastOpenAt": null,
"lastClickAt": null,
"suppressedAt": "2026-10-01T08:00:00.000Z",
"engagementAt": null,
"suppressionNote": null,
"customFields": {},
"updatedAt": "2026-10-01T08:00:00.000Z",
"stats90d": {
"received": 0,
"opened": 0,
"clicked": 0,
"bestHour": null
},
"lists": [],
"recentActivities": [
{
"id": "consent-9",
"type": "status_change",
"at": "2026-10-01T08:00:00.000Z",
"title": "suppressed"
},
{
"id": "consent-8",
"type": "status_change",
"at": "2026-10-01T08:00:00.000Z",
"title": "purpose_assigned"
},
{
"id": "consent-10",
"type": "unsubscribed",
"at": "2026-10-01T08:00:00.000Z",
"title": "admin"
}
],
"orders": [],
"orderTotals": [],
"editedFields": [],
"sources": []
}[
{
"id": 1207,
"guid": null,
"emailaddress": "eva.horakova@example.cz",
"domain": "example.cz",
"name": "Eva",
"surname": "Horáková",
"titlesbefore": null,
"titlesafter": null,
"salution": null,
"company": null,
"street": null,
"town": null,
"postalcode": null,
"country": null,
"cellphone": null,
"phone": null,
"language": "cs_CZ",
"birthday": null,
"nameday": null,
"gender": null,
"notes": null,
"realname": "Eva Horáková",
"blacklisted": 0,
"is_blacklisted": false,
"hardbounced": 0,
"softbounced": 0,
"created": "2026-10-01 10:00:00",
"updated": "2026-10-01 10:00:00",
"created_at": "2026-10-01 10:00:00",
"last_updated_at": "2026-10-01 10:00:00",
"origin": "manual",
"contactlists": [],
"customfields": [],
"event": "new_contact"
}
][
{
"id": 1207,
"guid": null,
"emailaddress": "eva.horakova@example.cz",
"domain": "example.cz",
"name": "Eva",
"surname": "Horáková",
"titlesbefore": null,
"titlesafter": null,
"salution": null,
"company": null,
"street": null,
"town": null,
"postalcode": null,
"country": null,
"cellphone": null,
"phone": null,
"language": "cs_CZ",
"birthday": null,
"nameday": null,
"gender": null,
"notes": null,
"realname": "Eva Horáková",
"blacklisted": 0,
"is_blacklisted": false,
"hardbounced": 0,
"softbounced": 0,
"created": "2026-10-01 10:00:00",
"updated": "2026-10-01 10:00:00",
"created_at": "2026-10-01 10:00:00",
"last_updated_at": "2026-10-01 10:00:00",
"origin": "manual",
"contactlists": [],
"customfields": [],
"event": "unsubscribed_contact",
"reason": "admin",
"unsubscribed_contactlist_ids": [],
"campaign_type": null,
"campaign_id": null,
"email_statistic_id": null,
"trigger_event": null
}
]3. Příjemce v Pythonu (Flask)
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
USER = os.environ['POSTARNA_HOOK_USER'] # login z basic_auth
PASSWORD = os.environ['POSTARNA_HOOK_PASSWORD'] # heslo z basic_auth
def authorized():
auth = request.authorization
return bool(auth) and hmac.compare_digest(auth.username or '', USER) \
and hmac.compare_digest(auth.password or '', PASSWORD)
@app.post('/postarna/webhook')
def postarna_webhook():
if not authorized():
abort(401)
payload = request.get_json(silent=True)
items = payload if isinstance(payload, list) else [payload]
for item in items:
event = item.get('event')
if event == 'new_contact':
crm_upsert_contact(email=item['emailaddress'], first_name=item.get('name'),
last_name=item.get('surname'))
elif event == 'unsubscribed_contact':
crm_mark_unsubscribed(email=item['emailaddress'], reason=item.get('reason'))
return '', 204 # 2xx do 30 s, jinak přijde znovu
def crm_upsert_contact(email, first_name, last_name):
... # založit nebo upravit podle e-mailu (opakované doručení nevadí)
def crm_mark_unsubscribed(email, reason):
... # nastavit příznak (opakované doručení nevadí)4. Příjemce v Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.use(express.json({ limit: '1mb' }));
const USER = process.env.POSTARNA_HOOK_USER;
const PASSWORD = process.env.POSTARNA_HOOK_PASSWORD;
function same(a, b) {
const x = Buffer.from(a ?? '');
const y = Buffer.from(b ?? '');
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function authorized(req) {
const [scheme, value] = (req.get('Authorization') ?? '').split(' ');
if (scheme !== 'Basic' || !value) return false;
const [user, ...rest] = Buffer.from(value, 'base64').toString('utf8').split(':');
return same(user, USER) && same(rest.join(':'), PASSWORD);
}
app.post('/postarna/webhook', async (req, res) => {
if (!authorized(req)) return res.sendStatus(401);
const items = Array.isArray(req.body) ? req.body : [req.body];
for (const item of items) {
if (item.event === 'new_contact') await crmUpsertContact(item.emailaddress, item.name, item.surname);
if (item.event === 'unsubscribed_contact') await crmMarkUnsubscribed(item.emailaddress, item.reason);
}
res.sendStatus(204); // 2xx do 30 s, jinak přijde znovu
});
app.listen(8080);5. Pravidla dobrého příjemce
- Odpovídejte rychle: delší zpracování dejte do fronty a hned vraťte 2xx. Po 30 sekundách se volání počítá jako chyba.
- Snesete opakování: stejná položka může přijít víckrát. Zpracování dělejte idempotentní (upsert podle e-mailu, nastavení příznaku), u e-mailových událostí si pamatujte
id. - Neodpovídejte chybou na neznámou událost: vraťte 2xx, jinak se webhook po 10 chybách vypne.
- Hlídejte stav:
GET /api/v3/web-hooksukazujeactive,last_response_code,last_call_timeaerrors_in_row. - Odhlášení v CRM pošlete zpátky do Poštárny přes
POST /api/v1/tenants/{tenant}/contacts/{id}/unsubscribe, ať obě strany drží stejný stav.