· 8 min čtení

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.

BackendIntegrationsB2B

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:

  1. Verified Meta Business Account - fotky občanky, výpis z OR firmy, doménová verifikace přes DNS TXT
  2. WhatsApp Business profile - schválené jméno, kategorie ("Finanční služby"), ikona
  3. 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íš:

  1. GET /v23.0/{media-id} → response obsahuje url a mime_type
  2. GET {url} s Bearer tokenem → binary blob
  3. 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.

DetailVlastní tabulkaStripe metadata
Zdroj pravdyTvoje DBStripe
Sync s billingemManuálníAutomatic
Lookup latence5 ms (Postgres)80 ms (Stripe API)
Náklady$0Stripe API call
Hodí se proHigh-frequency lookupLow-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

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