Recipes

Retry safely, handle sends of unknown outcome, mark reads, and pace volume.

Short patterns for the situations that cause the most support questions. Each one points to the page that has the full reference.

1. Know which errors are safe to retry

ResponseSafe to retry as is?Why
429 rate_limited, gateway_busyYes, after Retry-AfterNothing was sent. The request was never queued.
429 sandbox_allowance_exceededAfter resets_atThe sandbox channel reached an allowance. Nothing was sent.
502 media_upload_failedYesThe upload to WhatsApp failed and nothing was sent.
503 lane_reclaimed with ambiguous: falseYes, after Retry-AfterRefused before it was sent.
503 lane_reclaimed with ambiguous: trueOnly after checkingIt was sent and the outcome is unknown. A group add may already have applied.
504 send_unconfirmedNoThe message may have been delivered. See recipe 3.
502 receipt_partialNoSome read receipts already went out. See recipe 4.
Other 500, 502, 503Idempotent reads: with backoff. Writes: inspect before retrying.A write may have succeeded before its response failed. List or read the changed state first; never re-create a group without reading/listing first.

The API has no idempotency key. A 500, 502 or 503, or a lost response, can leave a write possibly applied. Inspect before retrying writes; if a send cannot be confirmed, handle it as possibly delivered. POST messages/read sends receipts and is a write. See Errors for every code and Limits for the allowances.

2. A sender that handles 429 and unknown outcomes

js
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function sendText(to, text) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(`${API}/sessions/${CHANNEL}/messages/text`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ to, text }),
    });
    if (res.ok) return res.json();
    const err = await res.json().catch(() => ({}));
    if (res.status === 429 && (err.code === 'rate_limited' || err.code === 'gateway_busy')) {
      const wait = Number(res.headers.get('retry-after') ?? err.retry_after ?? 2);
      await sleep((wait + Math.random()) * 1000);
      continue;
    }
    if (err.code === 'send_unconfirmed' || res.status >= 500) {
      return { possiblyDelivered: true, logId: err.id };   // never resend automatically; see recipe 3
    }
    throw Object.assign(new Error(err.code ?? `http_${res.status}`), { err });
  }
  throw new Error('gave up');
}

Only rate_limited and gateway_busy are retried here. Every other 429 is thrown to the caller on purpose:

  • daily_send_cap_exceeded can mean an exhausted positive daily ceiling: wait until resets_at at Bangkok midnight. cap: 0 is a stop that waiting does not lift; contact support. If the error reports unreliable counting, inspect it before retrying.
  • whatsapp_rate_limited means WhatsApp throttled the number, and it sends no Retry-After. Back off for minutes, not seconds.
  • sandbox_allowance_exceeded carries resets_at, which can be hours away.

3. After `send_unconfirmed`

Treat the message as possibly delivered. Nothing in the API can settle it either way.

  • The id in the error (it starts with ambiguous:) names a row WAgate wrote to the chat log to record the attempt. The row is marked ambiguous: true whether or not the message arrived. Finding it does not mean the message was delivered.
  • Not finding it does not mean the message was not sent. If WAgate could not write the row, the error has no id at all, and the message may still have gone out.
  • The id is not a WhatsApp message ID. No message.status event will name it.

What to do:

  • Do not resend automatically. Mark the send as "possibly delivered" in your own records.
  • Where it matters, confirm with the recipient another way, such as their reply, before you send again.
  • If you must resend without confirmation, accept that the recipient may get the message twice.

4. Mark a group read without double-receipting

If POST messages/read answers receipt_partial, the body has receipted, the number of messages already marked before the failure. It is a count, not a list of ids, so do not send the same request again. Resending can repeat receipts that already went out. If you need the rest marked, send a new request for the newest message you still need marked and accept that a receipt may be repeated.

5. Deduplicate webhooks

Store the X-Wagate-Delivery header for every event you process. A retry of the same event reuses it.

js
if (await alreadyProcessed(deliveryId)) return res.sendStatus(200);
await markProcessed(deliveryId);   // before the work, or in the same transaction
res.sendStatus(200);

See Webhooks for signing and retries.

6. Add people to a group safely

A group add takes up to 50 people per request and answers one result per person.

js
async function addOne(group, jid) {
  const res = await post(`/groups/${group}/participants`, { action: 'add', participants: [jid] });
  const r = res.results[0];
  if (r.status === 'unknown') return readGroupAndCheck(group, jid); // may have applied
  return r;
}

A person with status error_409 is already in the group, which for an add is a success. Add a few people at a time, with random gaps. If you get lane_reclaimed with ambiguous: true, inspect membership before retrying: the add may already have applied. A participant already present means success. See Groups.

7. A sender with random gaps

js
async function paced(jobs, minMs = 2000, maxMs = 9000) {
  for (const job of jobs) {
    await job();
    await sleep(minMs + Math.random() * (maxMs - minMs));
  }
}

The numbers are an example, not a limit. WAgate imposes no send timing floor on paid channels; request limits and any send cap still apply (see Limits). WhatsApp reacts to regular, robotic timing, so vary your gaps. See Numbers and safety.

8. Alert when a channel disconnects

Subscribe a receiver to session.status, and list it in events. When payload.state is anything other than CONNECTED, alert yourself and stop sending. The possible states are NEW, PAIRING, CONNECTED, DISCONNECTED and BLOCKED. See Webhook events.

Recipes · WAgate docs