WhatsApp Business API pro B2B: webhook handler a Stripe sync
Reálný setup WhatsApp Business API pro Marušku - Meta verification, HMAC SHA-256, idempotency, 5min TTL media download, Stripe customer.metadata.whatsapp_phone trick. TypeScript.
V Marušce - AI účetní asistent pro mikrofirmy - uživatelé fotí účtenky z WhatsAppu. Žádný app store, žádný "stáhněte si naši appku". Pošleš foto na WhatsApp číslo Marušky, dostaneš odpověď s kategorií a částkou, foto se pokladá v Stripe-vázaném účtu. Vypadá to triviálně. Není. Cesta od první žádosti k Meta až po produkční flow trvala 3 týdny - z toho 2 týdny čekání na Meta verification.
Tohle je celý setup, včetně chyb, na kterých jsem se spálil.
Architektura
WhatsApp uživatel → Meta Cloud API → webhook na Vercel
↓
HMAC verifikace
↓
idempotency check
↓
media ID → CDN URL fetch (TTL 5 min)
↓
Anthropic Vision OCR
↓
Stripe customer lookup
(přes metadata.whatsapp_phone)
↓
record entry + reply zpět
Webhook handler je single Vercel route. Žádná queue, žádný worker - pro Marušku objem ~200 zpráv/den, single-route stack stačí.
Krok 0: Meta business verification (2 týdny čekání)
Tohle byla nejhorší část. WhatsApp Business API není veřejný self-service produkt. Musíš mít:
- Verified Meta Business Account - fotky občanky, výpis z OR firmy, doménová verifikace přes DNS TXT
- WhatsApp Business profile - schválené jméno, kategorie ("Finanční služby"), ikona
- Approved templates pro outbound zprávy - když ty píšeš první, musí to být z templatu, ne ad-hoc text
Z 5 templatů, které jsem submitoval, 3 prošly napoprvé (60 %). Zbylé dva chtěly copy editing (Meta odmítla "Vaše účtenka byla zpracována, kategorie: %1$s" jako "promotional content disguised as service" - musel jsem přidat doslova "Toto je servisní zpráva.").
Lessons z téhle fáze:
- Začni verification ihned, ještě než máš app. Trvá 5-14 dní.
- Sandbox number ti Meta dá hned, ale z něj posíláš jen na 5 test čísel. Pro produkci potřebuješ verification.
- Templaty submituj v jedné dávce. Meta rozhoduje per-template, ale review-uje je společně. Submit → 24 hod → výsledek.
Webhook handler: HMAC verifikace
Meta podepisuje každý webhook payload pomocí HMAC SHA-256 hlavičkou X-Hub-Signature-256. Bez ověření přijímáš spoofnuté zprávy.
import crypto from 'node:crypto';
import { headers } from 'next/headers';
const APP_SECRET = process.env.META_APP_SECRET!;
async function verifySignature(req: Request, raw: string): Promise<boolean> {
const sig = (await headers()).get('x-hub-signature-256');
if (!sig?.startsWith('sha256=')) return false;
const expected = crypto
.createHmac('sha256', APP_SECRET)
.update(raw)
.digest('hex');
const provided = sig.slice('sha256='.length);
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}
export async function POST(req: Request) {
const raw = await req.text(); // raw body, ne JSON parse
if (!(await verifySignature(req, raw))) {
return new Response('forbidden', { status: 403 });
}
const payload = JSON.parse(raw);
return handleWebhook(payload);
}Kritický detail: počítáš HMAC z raw bodu, ne z JSON.stringify(parsed). Meta podepisuje original payload, takže když ho re-stringifujš, dostaneš jiný hash a verifikace failne. Tohle mě stálo večer.
Idempotency: Meta dělá retries
Když tvůj webhook nevrátí 200 do 20 sekund, Meta retry-uje stejný payload až 7×. Bez idempotency riskuješ duplicitní recordy.
import { kv } from '@vercel/kv';
async function isAlreadyProcessed(messageId: string): Promise<boolean> {
const key = `wa:msg:${messageId}`;
const set = await kv.set(key, '1', { nx: true, ex: 86_400 });
return set === null; // nx fail = už tam je
}
async function handleWebhook(payload: WAPayload) {
for (const change of payload.entry?.[0]?.changes ?? []) {
for (const msg of change.value.messages ?? []) {
if (await isAlreadyProcessed(msg.id)) {
continue; // tichý skip
}
await processMessage(msg);
}
}
return new Response('ok'); // vždy 200, jinak Meta retry-uje navždy
}kv.set(key, ..., { nx: true }) je atomic - buď nastaví, nebo vrátí null pokud klíč existuje. Žádný race condition mezi check a write.
Media download: 3-step flow s 5min TTL
Tohle je nejvíc nepříjemná část API. Když uživatel pošle foto, webhook obsahuje jen media ID, ne URL. Musíš:
- GET
/v23.0/{media-id}→ response obsahujeurlamime_type - GET
{url}s Bearer tokenem → binary blob - Vše do 5 minut od přijetí webhooku - pak Meta media URL invaliduje
async function downloadMedia(mediaId: string, accessToken: string): Promise<Buffer> {
// Step 1: získej CDN URL
const meta = await fetch(`https://graph.facebook.com/v23.0/${mediaId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then((r) => r.json() as Promise<{ url: string; mime_type: string }>);
// Step 2: stáhni binární soubor
const blob = await fetch(meta.url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!blob.ok) throw new Error(`media download failed: ${blob.status}`);
return Buffer.from(await blob.arrayBuffer());
}Praktický důsledek: stahuj média synchronně v handleru. Žádné "uložím do queue a stáhnu později" - do 5 minut to nemusíš stihnout, pokud queue má backlog. Maruška stahuje, OCRuje a odpovídá v jednom requestu, p95 7 sekund.
Stripe customer lookup přes metadata
Tady je trik. Stripe má customers/search endpoint s query syntaxí. Místo držení vlastní mapping tabulky phone → customer_id ukládám telefon jako metadata na Stripe Customeru a query-uju Stripe.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function findCustomerByPhone(phone: string): Promise<Stripe.Customer | null> {
const result = await stripe.customers.search({
query: `metadata['whatsapp_phone']:'${phone}'`,
limit: 1,
});
return result.data[0] ?? null;
}
async function ensureCustomer(phone: string, name?: string): Promise<Stripe.Customer> {
const existing = await findCustomerByPhone(phone);
if (existing) return existing;
return stripe.customers.create({
name: name ?? `WhatsApp ${phone}`,
metadata: { whatsapp_phone: phone },
});
}Proč ne vlastní tabulka? Jeden zdroj pravdy. Stripe je už můj billing engine. Když customer cancelne subscription a vrátí se za půl roku, metadata['whatsapp_phone'] ho najde okamžitě, žádná synchronizace mezi DB a Stripe.
| Detail | Vlastní tabulka | Stripe metadata |
|---|---|---|
| Zdroj pravdy | Tvoje DB | Stripe |
| Sync s billingem | Manuální | Automatic |
| Lookup latence | 5 ms (Postgres) | 80 ms (Stripe API) |
| Náklady | $0 | Stripe API call |
| Hodí se pro | High-frequency lookup | Low-frequency lookup (chat) |
Pro WhatsApp flow (200 zpráv/den) je 80ms naprosto v pohodě. Pro 50 000 lookupů/den bys potřeboval cache.
Reply zpět: template vs free-form
Pravidlo: Free-form text můžeš poslat jen v 24-hodinovém okně po posledním user messagi. Po 24 hodinách musíš použít approved template.
Maruška v 95 % případů odpovídá v okně:
async function sendText(to: string, text: string) {
const r = await fetch(`https://graph.facebook.com/v23.0/${PHONE_NUMBER_ID}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${WA_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to,
type: 'text',
text: { body: text },
}),
});
if (!r.ok) throw new Error(`send failed: ${r.status} ${await r.text()}`);
}Pro připomínky ("Včera jsi nedoplatil DPH") používám template payment_reminder se schváleným copy a parametry.
Lessons
- Začni Meta verification ASAP. 2 týdny čekání jsou normál, ne anomálie.
- HMAC z raw body, nikdy ne z re-stringified JSON.
- Idempotency přes KV s nx flagem je nejjednodušší pattern. Žádné distribuované locky.
- Media download synchronně v handleru - 5min TTL je tvrdé.
- Stripe metadata jako mapping table šetří synchronizační kód. Pro low-frequency lookups je perfektní.
- Template approval rate ~60 % v první iteraci. Plánuj 2-3 kola edits.
- Vždy vrať 200, i když internal failure. Meta jinak retry-uje 7×.
Co dál
- Maruška case study → - produkt, kde tahle integrace běží
- Stripe Checkout vs Subscriptions → - návazný blog o billing modelech
- Multi-tenant Postgres → - jak Maruška řeší izolaci dat mezi firmami
Pokud řešíš WhatsApp Business API integraci pro vlastní B2B produkt, napiš mi. Setup za 1-2 dny + čekání na Meta. Většinu chyb znám napaměť.