Skip to content

2026-09-14 · 8 min read

WhatsApp Business API: delivery tracking, tier pacing, and the 24-hour window, done properly

What it took to send event invitations to hundreds of guests on the Meta Cloud API from inside a CRM: a rank-guarded status machine, one worker holding credentials, a drain loop for the messaging tier, and a quality circuit breaker.

Hessa Events sends wedding and event invitations to guest lists over WhatsApp and needs to know, per guest, whether the message was delivered, read, and answered. This is the design that runs it, and the parts that were expensive to learn.

One small worker holds the credentials

The CRM on Vercel owns the screens and the data. A separate Node.js service on Railway is the only thing that ever sees the Meta access token and app secret. The CRM triggers a send with one authenticated call; the worker claims the pending guests atomically (pending to queued), replies immediately with how many it took, then sends in the background with bounded concurrency and backoff. Vercel function timeouts stop mattering, the webhook is always warm, and rotating the token touches one place.

Statuses arrive out of order; rank them

Meta sends sent, delivered, read, and failed as separate webhook events, and they do not arrive in order. The naive update, set status to whatever came last, will happily downgrade read to delivered. Each status has a rank, and an update only applies if its rank is higher than the stored one. Every raw payload also lands in a log table, so any dispute can be traced to what Meta actually sent.

Button replies are template-specific

A quick-reply tap is a message with a context id pointing at the original. Joining on that id, not on the phone number, ties the tap to the exact campaign message, which matters when the same guest is invited to several events. The meaning of each button (attending, not attending, maybe) comes from the template's own definition, stored per template, so the dashboard shows every possible answer at zero before anyone taps and never hard-codes yes or no.

The 24-hour messaging tier is not an error

Meta caps how many new conversations a number may start per rolling 24 hours, by tier. A campaign larger than the window is normal. The worker sends what the window allows, leaves the rest pending, and a drain loop resumes them every few minutes as slots free up; 500 guests finish by themselves over about two days. Usage counts distinct phones across campaign and inbox sends, unioned on phone, because one send lands in both tables.

Separately, error 131049 is a per-recipient marketing cap Meta does not publish. Pacing cannot avoid it; those rows get a retry-after of 24 hours and one automatic retry.

Quality rating is a circuit breaker

The number's quality rating reflects recipient behaviour: blocks, spam reports, unread marketing. Red pauses the drain loop and shows a warning; yellow warns but sends. A manual send is never blocked, because that is a deliberate operator act. There is deliberately no hourly drip: the tier already is Meta's rate control.

Two-way inbox: enforce the window server-side

Inside 24 hours of a customer's last message, agents can reply free-form; outside it, only with an approved template. The composer shows this, but the server enforces it too, so a stale tab gets a readable error instead of Meta's 131047. Live updates use Supabase Realtime on the message tables; media is fetched lazily from Meta into our own storage before Meta's roughly 30-day deletion.

Performance rules that keep it fast

  • Never call Meta on a hot path uncached. The account analytics call measures about 2.3 seconds; cache it and put it behind Suspense.
  • Snap cache-key timestamps to the TTL bucket, or every second mints a new key and nothing ever hits.
  • Do not select the raw webhook payload column when rendering a thread; it was 56% of the response.
  • Bound every list query; a thread list that scanned all messages to find the newest per conversation became a database function.

All of this is inside the company's own system, not a separate messaging tool, which is the whole point: the guest list, the RSVP, and the HR data live in one place.