· 8 min de lectura

WhatsApp Business API for B2B: webhook handler + Stripe sync

Real WhatsApp Business API setup for Maruška - Meta verification, HMAC SHA-256, idempotency, 5-min TTL media download, Stripe customer.metadata.whatsapp_phone trick. TypeScript code.

BackendIntegrationsB2B

In Maruška - an AI accounting assistant for micro-businesses - users photograph receipts from WhatsApp. No app store, no "download our app". You send a photo to Maruška's WhatsApp number, you get a reply with a category and amount, and the receipt lands on a Stripe-linked account. Looks trivial. It's not. The road from first request to Meta to a production flow took 3 weeks - 2 of those just waiting on Meta verification.

Here's the full setup, including the mistakes I burned myself on.

Architecture

WhatsApp user → Meta Cloud API → webhook on Vercel
                                     ↓
                              HMAC verification
                                     ↓
                              idempotency check
                                     ↓
                       media ID → CDN URL fetch (TTL 5 min)
                                     ↓
                              Anthropic Vision OCR
                                     ↓
                          Stripe customer lookup
                       (via metadata.whatsapp_phone)
                                     ↓
                          record entry + reply back

The webhook handler is a single Vercel route. No queue, no worker - Maruška handles ~200 messages/day, single-route stack is enough.

Step 0: Meta business verification (2 weeks of waiting)

This was the worst part. WhatsApp Business API is not a self-service public product. You need:

  1. Verified Meta Business Account - ID photos, company registry extract, domain verification via DNS TXT
  2. WhatsApp Business profile - approved name, category ("Financial Services"), icon
  3. Approved templates for outbound messages - when you message first, it must be from a template, not ad-hoc text

Of 5 templates I submitted, 3 passed first try (60%). The other two needed copy editing (Meta rejected "Your receipt has been processed, category: %1$s" as "promotional content disguised as service" - I had to literally add "This is a service message.").

Lessons from this phase:

  • Start verification immediately, before you have an app. Takes 5-14 days.
  • Sandbox number Meta gives you instantly, but you can only message 5 test numbers from it. Production needs verification.
  • Submit templates in one batch. Meta decides per-template but reviews them together. Submit → 24h → result.

Webhook handler: HMAC verification

Meta signs every webhook payload with HMAC SHA-256 in the X-Hub-Signature-256 header. Without verification you accept spoofed messages.

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, not JSON.parse
  if (!(await verifySignature(req, raw))) {
    return new Response('forbidden', { status: 403 });
  }
  const payload = JSON.parse(raw);
  return handleWebhook(payload);
}

Critical detail: compute HMAC over the raw body, not over JSON.stringify(parsed). Meta signs the original payload, so if you re-stringify it you get a different hash and verification fails. This cost me an evening.

Idempotency: Meta retries

If your webhook doesn't return 200 within 20 seconds, Meta retries the same payload up to 7 times. Without idempotency you risk duplicate records.

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 = key already there
}
 
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; // silent skip
      }
      await processMessage(msg);
    }
  }
  return new Response('ok'); // always 200, otherwise Meta retries forever
}

kv.set(key, ..., { nx: true }) is atomic - either sets or returns null if the key exists. No race condition between check and write.

Media download: 3-step flow with 5-min TTL

This is the most awkward part of the API. When a user sends a photo, the webhook contains only a media ID, not a URL. You have to:

  1. GET /v23.0/{media-id} → response includes url and mime_type
  2. GET {url} with the Bearer token → binary blob
  3. All within 5 minutes of receiving the webhook - Meta invalidates the media URL after that
async function downloadMedia(mediaId: string, accessToken: string): Promise<Buffer> {
  // Step 1: get the 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: download the binary
  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());
}

Practical implication: download media synchronously in the handler. No "queue it and download later" - you might miss the 5-minute window if the queue has backlog. Maruška downloads, OCRs and replies in a single request, p95 7 seconds.

Stripe customer lookup via metadata

Here's the trick. Stripe has a customers/search endpoint with a query syntax. Instead of holding a separate phone → customer_id mapping table, I store the phone as metadata on the Stripe Customer and query 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 },
  });
}

Why not a custom table? One source of truth. Stripe is already my billing engine. When a customer cancels their subscription and comes back six months later, metadata['whatsapp_phone'] finds them instantly, no DB ↔ Stripe sync.

DetailCustom tableStripe metadata
Source of truthYour DBStripe
Billing syncManualAutomatic
Lookup latency5 ms (Postgres)80 ms (Stripe API)
Cost$0Stripe API call
Good forHigh-frequency lookupLow-frequency lookup (chat)

For a WhatsApp flow (200 messages/day) 80ms is fine. For 50,000 lookups/day you'd need a cache.

Reply back: template vs free-form

Rule: You can send free-form text only inside a 24-hour window after the user's last message. After 24 hours you must use an approved template.

Maruška replies inside the window in 95% of cases:

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

For reminders ("You missed yesterday's VAT payment") I use the payment_reminder template with approved copy and parameters.

Lessons

  • Start Meta verification ASAP. 2 weeks of wait is the norm, not an anomaly.
  • HMAC over raw body, never over re-stringified JSON.
  • Idempotency via KV with the nx flag is the simplest pattern. No distributed locks.
  • Download media synchronously in the handler - the 5-min TTL is hard.
  • Stripe metadata as mapping table saves sync code. For low-frequency lookups it's perfect.
  • ~60% template approval rate on first iteration. Plan for 2-3 rounds of edits.
  • Always return 200, even on internal failure. Otherwise Meta retries 7×.

What's next

If you're integrating WhatsApp Business API for your own B2B product, drop me a line. Setup in 1-2 days plus Meta wait. I know most of the gotchas by heart.