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.
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:
- Verified Meta Business Account - ID photos, company registry extract, domain verification via DNS TXT
- WhatsApp Business profile - approved name, category ("Financial Services"), icon
- 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:
- GET
/v23.0/{media-id}→ response includesurlandmime_type - GET
{url}with the Bearer token → binary blob - 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.
| Detail | Custom table | Stripe metadata |
|---|---|---|
| Source of truth | Your DB | Stripe |
| Billing sync | Manual | Automatic |
| Lookup latency | 5 ms (Postgres) | 80 ms (Stripe API) |
| Cost | $0 | Stripe API call |
| Good for | High-frequency lookup | Low-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
- Maruška case study → - the product where this integration runs
- Stripe Checkout vs Subscriptions → - follow-up post on billing modes
- Multi-tenant Postgres → - how Maruška keeps client data isolated
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.