Skip to Content
WhatsApp Agent Docs

Operations

Session states and relinking, outbox states and operator actions, the retry and error matrix, rate limits, and traceability.

Session states and relinking

The linked WhatsApp account is the single point of failure for a workspace. The admin app shows the session status; the engine syncs it from session.* webhooks and from POST /admin/session/status.

StateMeaningOperator action
created, initializingSession exists or Chromium is starting.Wait.
qr_readyWaiting for a scan or pairing code.Link the phone (see Onboarding).
authenticatingThe phone accepted the link.Wait.
readyLinked and sending. Connection active.None.
disconnectedThe link dropped. A reason of logout, unpair, unlink, conflict or banned also sets the connection disconnected. Other reasons are transient and the gateway reconnects.If it persists, relink.
action_required, failedThe gateway needs attention or the session could not start.Run the connect flow again, then check gateway logs and memory.

Connection status. While the connection is not active, inbound events are ignored and queued replies are discarded as "channel inactive". Customers are not answered, so relink promptly.

Relink. Run the connection flow again and scan a new QR code (or use a pairing code). The same session and workspace are reused. If the account was restricted or banned, a new scan will not help until WhatsApp lifts it; see Ban-risk practices.

Disconnect on purpose. POST /admin/session/logout unlinks the device from the phone and marks the connection disconnected. Do this when handing the number back or retiring a workspace.

Restriction events. session.restriction webhooks are stored in channel_event. Treat one as a warning: reduce traffic and review what the number is sending.

Gateway health. The compose health check calls GET /api/health/ready. A gateway restart reconnects ready sessions from the openwa-data volume; losing that volume unlinks every account.

Staff replies from the phone

Staff can reply from the linked phone as well as from the admin inbox. A message.sent event that the engine did not send puts the conversation in human mode, so the assistant stops answering it. Echoes of the engine's own sends are matched to the outbound row (the event is delayed 8 seconds to let the send job record it first) and do not trigger takeover.

The outbox

Every outgoing message, whether an assistant reply or a staff reply, is first written to outbox and then sent by the send job. OpenWA has no idempotency key on sends, so the design is at most once: if there is any doubt a message was sent, the system stops and surfaces it instead of risking a duplicate.

Diagram: Outbox state machine.

Idempotency keys: reply:<inboundMessageId> for assistant replies, staff:<uuidv7> for staff replies. The key is unique per workspace, so a retried job cannot create a second reply.

Claim and freshness checks

Before sending, the job locks the row (FOR UPDATE) and discards it unless all of these hold:

CheckDiscard reason
Conversation, contact (or group), and channel connection still existconversation removed
Channel connection is activechannel inactive
Contact is not blockedcontact blocked
expires_at is in the future (1 hour for assistant replies, 24 hours for staff replies)expired
An assistant reply is not being sent into a conversation a human has taken over (handoff notices are exempt)human took over
Every cited source version is still the current version of an active sourceevidence changed

States and what operators do

StateMeaningOperator action
pendingWaiting for the send job, or between retries after a retryable error.Normally none. If it lingers past a few minutes, check the QStash dashboard for failed or dead-lettered deliveries, the session state, and the engine logs by trace ID. Expired rows are discarded when next processed.
sendingClaimed and the gateway call is in flight (including the human-paced typing delay of 1.2 to 7 seconds). Transient.None. If the job is redelivered while the row is still sending, the row becomes unknown.
sentThe gateway accepted the message and returned a message ID. The outbound message row is created. Delivery and read arrive later as message.ack webhooks.None. Watch delivery_status in the inbox.
unknownThe request may or may not have reached WhatsApp (network failure, a 5xx from the gateway other than 503, or the job was interrupted mid-send). Never retried automatically.Open the conversation in the inbox, or look at the phone itself, and check whether the customer has the message. If not, send a staff reply. There is no one-click resend by design, to avoid duplicates.
failedThe gateway refused the send with a non-retryable error. last_error holds the HTTP status and message.Act on the status (see the matrix). Common: 401 or 403 means a wrong OPENWA_API_KEY; 404 or 400 usually means a bad chat id or a session that no longer exists. Reply manually if the customer still needs an answer.
discardedAn intentional safety stop (table above). Not an error.Usually none. For evidence changed or human took over, the customer may be waiting: reply manually or let their next message trigger a fresh answer.

The inbox lists outbox rows in pending, sending, unknown, and failed for each conversation so operators see them in context. A later message.failed event for a sent message marks the message failed in the inbox.

Retry and error matrix

Classification is by OpenWA HTTP status. The gateway returns { statusCode, message, error }.

LayerConditionBehavior
WebhookBad signature401, nothing stored.
WebhookSigned but malformed JSONRaw body kept under raw/openwa/invalid/, 200 so OpenWA stops retrying.
WebhookR2 or QStash failure5xx, OpenWA redelivers, dedup IDs make it safe.
QStashAny non-2xx from a jobRedelivered with backoff, up to 5 retries.
InboundAnswer rate limit hitRateLimitedError, 503, retried later.
InboundAnthropic 429, 5xx, 529, 408, networkRetryableAnswerError, 503, retried.
InboundUnexpected error500, logged with trace ID.
SendGateway 409 (session not ready), 429 or 503Nothing was sent. Row back to pending, 503, QStash retries.
SendGateway 401 or 403failed. Fix the API key.
SendOther 4xxfailed.
SendGateway 5xx other than 503, or a network error after the request was madeunknown.
IngestMissing object, too large, wrong type, key outside tenant prefixVersion failed with a message; job returns 200 (no retry).

Rate limits

LimitValueWhere it applies
Answers per workspace120 per minute, sliding window (Upstash Ratelimit)Before each model call. Exceeding it returns 503 so QStash retries later.
WhatsApp sending limitsNot published, and the gateway guarantees noneStaying low and conversational is the control. See Ban-risk practices.
Send pacingAbout 25 ms per character, clamped to 1.2 to 7 seconds, jitteredEvery outbound message.
Source upload5 MB per fileAdmin and ingest job.
Inbound media archive25 MB per fileInbound job.

Traceability

Every engine response carries X-Trace-Id (UUIDv7). The same ID is written to usage_event and audit_event rows and to error logs, and Workers observability is enabled. Raw OpenWA payloads are retained in R2 under raw/openwa/<date>/, which is the first place to look when a message seems to be missing. Keep an R2 lifecycle rule that matches your retention policy, because raw payloads contain customer message content.

Usage metering

usage_event records llm_tokens (with model, token breakdown, and cost in micro-USD) and ingest_chunk. WhatsApp itself bills nothing per message through OpenWA, so there is no per-message channel cost to meter. The admin Usage page shows the last 30 days in UTC days.

On this page