DEV Community

Cover image for WhatsApp Cloud API Rate Limits in Practice: Throughput, Pair Rate, Messaging Limits and Error Codes
WAPGROWAI for WAPGROWAI

Posted on

WhatsApp Cloud API Rate Limits in Practice: Throughput, Pair Rate, Messaging Limits and Error Codes

The WhatsApp Cloud API has four separate limits, and they fail in different ways. Throughput caps how many messages a business number handles per second (80 by default, 20 for coexistence numbers). The pair rate caps how often you can message one customer (about one message every 6 seconds). The messaging limit caps how many customers you can reach outside a 24-hour window in a day, across the whole business portfolio. Quality and spam limits stop sends that look unwanted. Pace every request against the first two, budget the third before you start a campaign, and treat each error code according to which limit it came from: some mean "wait a second", some "wait a day", some "stop".

The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.

The four limits

Limit Scope Default Error when hit
Throughput Business phone number 80 messages/second, 20 for coexistence 130429
Pair rate One business number → one customer 1 message every 6 seconds 131056
Messaging limit Business portfolio, moving 24 hours 250 to unlimited unique customers No dedicated code documented
Marketing per-user limit One customer Adaptive, no fixed number 131049

Throughput is per registered number. Meta's throughput guide says Cloud API "supports up to 80 messages per second (mps) by default", and that the figure is "inclusive of inbound and outbound messages and all message types". Replies coming in use the same budget as campaigns going out, so don't pace your sends at exactly 80. The upgrade to 1,000 mps is automatic, but only for numbers in a portfolio with an unlimited messaging limit that have reached 100,000 or more customers outside a service window in a moving 24 hours, with a quality rating of at least yellow. Coexistence numbers, the ones also used in the WhatsApp Business app, have a fixed 20 mps.

The pair rate is in Meta's rate limits overview: "1 message every 6 seconds to the same WhatsApp user". Short bursts are allowed, but they borrow from future quota, so a burst is followed by a wait. This is the one that bites chatbots and order updates: three messages in a row to one customer, sent as fast as your code can, is a burst.

The messaging limit is the daily cap on reaching customers who haven't messaged you in the last 24 hours. Meta's messaging limits page counts unique customers in a moving 24 hours, and it's shared: limits are "calculated and set at the business portfolio level and are shared by all business phone numbers within a portfolio". The tiers are 250, 2,000, 10,000, 100,000 and unlimited.

The marketing per-user limit is Meta deciding that one customer has had enough marketing for now. The error codes reference describes 131049 as a message "not delivered to maintain healthy ecosystem engagement".

Pace every request, not every batch

Throughput and the pair rate are both about time between requests, so the gate has to sit directly in front of each call to Meta. We learned this from a campaign sender that checked the rate at the start of each batch: the database work inside each batch took a varying time, so the sends at the end of one batch and the start of the next bunched together, and a number allowed 20 per second briefly received about 30. Moving the check to a per-number permit before every request fixed it.

A pacer per business number tracks two things: the next free slot for the number, and the next free slot for each customer.

// Spaces sends for one business number: a messages-per-second budget for the
// number, and a minimum gap between messages to the same customer.
export type PacerOptions = { mps: number; pairGapMs: number };

export const createPacer = ({ mps, pairGapMs }: PacerOptions) => {
  const slotMs = 1000 / mps;
  let nextNumberSlot = 0;
  const nextForRecipient = new Map<string, number>();

  // Returns the earliest time this message may go, and reserves that slot.
  const reserve = (recipient: string, now: number): number => {
    const at = Math.max(now, nextNumberSlot, nextForRecipient.get(recipient) ?? 0);
    nextNumberSlot = at + slotMs;
    nextForRecipient.set(recipient, at + pairGapMs);
    return at;
  };

  // After a 131056, push this customer back without slowing anyone else.
  const deferRecipient = (recipient: string, until: number) => {
    nextForRecipient.set(recipient, Math.max(nextForRecipient.get(recipient) ?? 0, until));
  };

  return { reserve, deferRecipient };
};
Enter fullscreen mode Exit fullscreen mode

With mps: 80, sends to different customers go out 12.5 ms apart. A second message to the same customer waits 6 seconds, while other customers' messages keep flowing past it. Set mps below the real limit to leave room for inbound traffic.

In production, the pacer's state has to be shared by every worker that sends from the number, for example as a Redis key per number. Two workers with their own in-memory pacers send at twice the rate.

Budget the messaging limit before you start

The messaging limit isn't something to discover halfway through a campaign. Count it before you start: how many customers in this send are outside a 24-hour window, and how many of the portfolio's daily slots are already used?

// Meta's messaging limit: unique customers reached outside a customer service
// window, in a moving 24 hours, shared by every number in the business portfolio.
const DAY_MS = 24 * 60 * 60 * 1000;

export const createDailyLimit = (limit: number) => {
  const lastReached = new Map<string, number>();

  const prune = (now: number) => {
    for (const [user, at] of lastReached) if (now - at >= DAY_MS) lastReached.delete(user);
  };

  // A customer already reached in the last 24 hours doesn't use a new slot.
  const tryReach = (user: string, now: number): boolean => {
    prune(now);
    if (!lastReached.has(user) && lastReached.size >= limit) return false;
    lastReached.set(user, now);
    return true;
  };

  const remaining = (now: number) => {
    prune(now);
    return Math.max(0, limit - lastReached.size);
  };

  return { tryReach, remaining };
};
Enter fullscreen mode Exit fullscreen mode

Two details matter. A customer you've already reached in the last 24 hours doesn't use a second slot. And the budget belongs to the portfolio, not the number, so two numbers running campaigns at once draw from the same pool. Only call tryReach for messages sent outside a customer service window; replies inside one don't count.

Your local count is an estimate. Meta's count is the real one, and it reports limit changes in the business_capability_update webhook's max_daily_conversations_per_business field. The reference lists TIER_* strings such as TIER_2K, while its example payload shows a plain number, so parse both. Update your stored limit from that webhook rather than hard-coding it.

Decide by error code

Meta's error reference says to build error handling around the error code, not the HTTP status or the message. Some codes mean "slow down", some mean "this customer, later", and some mean "stop":

// What to do with a failed send, decided by Meta's error code alone.
export type SendDecision =
  | { action: "retry"; afterMs: number }
  | { action: "retry-recipient"; afterMs: number }
  | { action: "hold-recipient"; afterMs: number }
  | { action: "pause-number" }
  | { action: "give-up" };

const SECOND = 1000;
const HOUR = 60 * 60 * SECOND;

// Meta's pair-rate guidance: retry after 4^X seconds, X counting up from 0.
export const backoffMs = (attempt: number, capMs = 15 * 60 * SECOND) => Math.min(4 ** attempt * SECOND, capMs);

export const decide = (code: number, attempt: number): SendDecision => {
  switch (code) {
    case 130429: // throughput reached for this number
    case 131057: // maintenance mode, e.g. a throughput upgrade (about a minute)
    case 80007: // WABA rate limit
    case 4: // app rate limit
      return { action: "retry", afterMs: backoffMs(attempt) };
    case 131056: // too many messages to this one user
      return { action: "retry-recipient", afterMs: backoffMs(attempt) };
    case 131049: // per-user marketing limit; retrying early can extend the hold
      return { action: "hold-recipient", afterMs: 24 * HOUR };
    case 131048: // sending restriction on the number; check its quality first
      return { action: "pause-number" };
    default: // 131026 undeliverable, 130472 experiment, and anything unknown
      return { action: "give-up" };
  }
};
Enter fullscreen mode Exit fullscreen mode

Each choice, and where it comes from:

  • 130429, 80007, 4: retry later. These are throughput and API rate limits. Meta's advice for each is to try again later or send less often; nothing is wrong with the message itself.
  • 131057: retry shortly. The account is in maintenance mode, for example while its throughput is upgraded, which takes up to about a minute.
  • 131056: retry this customer later. Only the one customer is affected. For throttling after a burst, Meta's Cloud API overview says to "retry after 4^X seconds", with X starting at 0 and going up after each failure, which is where backoffMs comes from.
  • 131049: hold this customer for a day. Meta's guidance is to wait at least 24 hours, and resending sooner can extend the hold. Marketing sent inside an open service window doesn't count toward this limit.
  • 131048: pause the number. Meta describes it as "restrictions on how many messages can be sent from this phone number". Pause, and check the number's quality in WhatsApp Manager before sending more.
  • Everything else: give up and record why. 131026, for example, means the message couldn't be delivered, often because the recipient isn't on WhatsApp, hasn't accepted the latest terms, or is on an old app version. Retrying won't change that.

The same codes can arrive two ways: in the response to your send request, or later in a status webhook with status: "failed" and an errors array. Route both through the same decision.

Put it together

One send passes through the pacer, then acts on the result:

import { decide } from "./classify.ts";
import type { createPacer } from "./pacer.ts";

export type Job = { to: string; attempt: number };
export type SendResult = { ok: true } | { ok: false; code: number };

export type Outcome =
  | { status: "sent" }
  | { status: "requeue"; at: number; job: Job }
  | { status: "pause-number" }
  | { status: "failed"; code: number };

// One send through the pacer; the caller owns the queue and acts on the outcome.
export const sendOne = async (
  job: Job,
  pacer: ReturnType<typeof createPacer>,
  clock: { now: () => number; sleepUntil: (t: number) => Promise<void> },
  send: (to: string) => Promise<SendResult>,
): Promise<Outcome> => {
  await clock.sleepUntil(pacer.reserve(job.to, clock.now()));
  const result = await send(job.to);
  if (result.ok) return { status: "sent" };

  const decision = decide(result.code, job.attempt);
  const next = { ...job, attempt: job.attempt + 1 };
  switch (decision.action) {
    case "retry":
      return { status: "requeue", at: clock.now() + decision.afterMs, job: next };
    case "retry-recipient":
    case "hold-recipient": {
      const at = clock.now() + decision.afterMs;
      pacer.deferRecipient(job.to, at);
      return { status: "requeue", at, job: next };
    }
    case "pause-number":
      return { status: "pause-number" };
    case "give-up":
      return { status: "failed", code: result.code };
  }
};
Enter fullscreen mode Exit fullscreen mode

The caller owns the queue: it puts requeued jobs back with their at time, stops the number's queue on pause-number, and records failed with its code so someone can see why a message didn't go. Two rules make the loop safe:

  • Requeue with a time, never in a tight loop. A retry that ignores at turns one rate-limit error into hundreds.
  • Cap the attempts. After a handful of retries, record the failure. A message that fails five times over an hour probably isn't going to succeed on the sixth.

Checklist

  • Pace every request per business number, and leave headroom for inbound traffic.
  • Keep 6 seconds between messages to the same customer.
  • Share the pacer's state across workers.
  • Budget the portfolio's messaging limit before a campaign, and update the tier from webhooks.
  • Decide by error code: retry, defer one customer, hold for a day, pause the number, or give up.
  • Back off with 4^X seconds and cap the attempts.
  • Handle the same codes from send responses and from failed-status webhooks.

WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: routing webhooks in a multi-tenant SaaS and handling webhooks in Node.js.

Top comments (0)