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.
| State | Meaning | Operator action |
|---|---|---|
created, initializing | Session exists or Chromium is starting. | Wait. |
qr_ready | Waiting for a scan or pairing code. | Link the phone (see Onboarding). |
authenticating | The phone accepted the link. | Wait. |
ready | Linked and sending. Connection active. | None. |
disconnected | The 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, failed | The 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.
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:
| Check | Discard reason |
|---|---|
| Conversation, contact (or group), and channel connection still exist | conversation removed |
Channel connection is active | channel inactive |
| Contact is not blocked | contact 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 source | evidence changed |
States and what operators do
| State | Meaning | Operator action |
|---|---|---|
pending | Waiting 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. |
sending | Claimed 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. |
sent | The 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. |
unknown | The 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. |
failed | The 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. |
discarded | An 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 }.
| Layer | Condition | Behavior |
|---|---|---|
| Webhook | Bad signature | 401, nothing stored. |
| Webhook | Signed but malformed JSON | Raw body kept under raw/openwa/invalid/, 200 so OpenWA stops retrying. |
| Webhook | R2 or QStash failure | 5xx, OpenWA redelivers, dedup IDs make it safe. |
| QStash | Any non-2xx from a job | Redelivered with backoff, up to 5 retries. |
| Inbound | Answer rate limit hit | RateLimitedError, 503, retried later. |
| Inbound | Anthropic 429, 5xx, 529, 408, network | RetryableAnswerError, 503, retried. |
| Inbound | Unexpected error | 500, logged with trace ID. |
| Send | Gateway 409 (session not ready), 429 or 503 | Nothing was sent. Row back to pending, 503, QStash retries. |
| Send | Gateway 401 or 403 | failed. Fix the API key. |
| Send | Other 4xx | failed. |
| Send | Gateway 5xx other than 503, or a network error after the request was made | unknown. |
| Ingest | Missing object, too large, wrong type, key outside tenant prefix | Version failed with a message; job returns 200 (no retry). |
Rate limits
| Limit | Value | Where it applies |
|---|---|---|
| Answers per workspace | 120 per minute, sliding window (Upstash Ratelimit) | Before each model call. Exceeding it returns 503 so QStash retries later. |
| WhatsApp sending limits | Not published, and the gateway guarantees none | Staying low and conversational is the control. See Ban-risk practices. |
| Send pacing | About 25 ms per character, clamped to 1.2 to 7 seconds, jittered | Every outbound message. |
| Source upload | 5 MB per file | Admin and ingest job. |
| Inbound media archive | 25 MB per file | Inbound 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.