Skip to main content

WhatsApp Ops Inbox

This feature gives PMS staff a two-way WhatsApp inbox: one list of conversations — owner/agent groups and guest 1:1 chats — where they can read incoming messages and reply, instead of WhatsApp being a one-way channel the app only pushes into.

It lives in admin-api under com.elivaas.pms.inbox, plus shared ingestion/send logic in whatsapp (com.elivaas.whatsapp.service) that is reused by the booking-announcement and Smart Collect features. The WhatsApp Groups module (WhatsAppGroupService) remains the transport for group sends; this feature adds the read side (conversations, threads) and the reply/write side on top of it.

How It Works

Meta calls the same public webhook endpoint used by WhatsApp Groups (POST /api/webhooks/whatsapp) for both group-management events and message/status events — the two are told apart by the field on each entry[].changes[] item.

Meta ──POST /api/webhooks/whatsapp──► WhatsAppWebhookController
│ verifies X-Hub-Signature-256

WhatsAppGroupService.handleWebhook(payload)
├─ persist the raw payload verbatim ── whatsapp_webhook_event (always, first, before anything
│ else runs — a downstream failure never loses the event)
└─ for each entry[].changes[]:
├─ field = group_lifecycle_update / group_settings_update / ... ── whatsapp_group* mirror
└─ field = "messages" ──► WhatsAppInboxIngestService.ingest(value)
├─ value.messages[] (inbound) ── upsert whatsapp_conversation, then
│ INSERT INTO whatsapp_message ... ON CONFLICT (wa_message_id) DO NOTHING
│ └─ only when a row was actually inserted: bump conversation unread_count,
│ advance last_message_at (monotonic), overwrite last_message_preview (not guarded)
└─ value.statuses[] (delivery receipts) ── advance the outbound row's status,
never regressing it (see the status ladder below)

(back in the controller, independent of the above)
├─ AnnouncementDeliveryFailureHandler ── unrelated: alerts owners on failed announcement sends
└─ WhatsAppMediaDownloadService.downloadPending() (@Async) ── pulls any media just marked
PENDING into S3 before Meta's short-lived media URL expires

The controller always returns 200 to Meta on success (even if ingestion throws internally) so Meta never retry-storms a transient bug; the raw event is already durably stored by the time anything else runs.

The two tables

whatsapp_webhook_event — the raw, append-only audit log everything above is built from:

ColumnMeaning
idRow id
event_typeBest-effort classification: the inner group type if present, else the change field (e.g. messages, group_create)
payloadThe full webhook body, verbatim, as JSONB
received_atWhen this app received it — the ordering key for replay

whatsapp_conversation — one row per thread, a group or a 1:1 with a single wa_user:

ColumnMeaning
idConversation id
channel_typeGROUP | DIRECT
group_idMeta group id; null for DIRECT
wa_userCounterparty phone number; null for GROUP
phone_number_idOur WABA phone-number-id, so a second business number never collides with an existing thread
titleDisplay name/subject
last_message_atRolled forward on every inbound/outbound touch via GREATEST(last_message_at, occurred_at) — monotonic, never regresses even if messages are processed out of order
last_message_previewOverwritten unconditionally with the just-processed message's preview on every inbound/outbound touch. Not ordering-guarded: unlike last_message_at, there is no GREATEST/timestamp check on this column, so a distinct message processed out of occurred_at order (e.g. during a replay, or if Meta's delivery batches arrive out of sequence) can revert the preview text to older content even while last_message_at itself stays correct. (An exact redelivery of the same wamid does not hit this path at all — see Idempotency below.)
unread_countTeam-shared unread counter; bumped only on a genuinely new inbound message
last_read_atWhen the thread was last marked read
statusOPEN | ARCHIVED

whatsapp_message — one row per message, either direction:

ColumnMeaning
idMessage id
conversation_idFK → whatsapp_conversation
wa_message_idMeta's wamid; unique, and the idempotency key for inbound inserts. Null on an outbound row until the send returns
directionINBOUND | OUTBOUND
sender_wa_user / sender_nameSet for inbound; Meta's contact profile name when it sent one
typeTEXT | IMAGE | DOCUMENT | AUDIO | VIDEO | STICKER | SYSTEM | UNSUPPORTED
bodyText body, or the media caption
context_wa_message_idThe wamid this message is a WhatsApp-level reply to, if any
originINBOX (a reply typed by staff) | ANNOUNCEMENT | TEMPLATE — see Booking Announcements
sent_by_user_idStaff user who sent it, for an INBOX outbound row
statusInbound: RECEIVED. Outbound: QUEUEDSENTDELIVEREDREAD, or FAILED
error_code / error_messageSet when a send or a status webhook reports failure
sent_at / delivered_at / read_atTimestamps for each rung of the status ladder
occurred_atMeta's timestamp — the ordering key for the thread. created_at is our own clock and is never used for ordering
wa_media_id, media_mime_type, media_file_name, media_sha256, media_size_bytes, media_s3_keyMedia metadata and, once stored, the S3 object key
media_statusPENDING | DOWNLOADING | STORED | FAILED; null when the message has no media
media_attemptsDownload attempts made so far, capped at maxAttempts
media_claimed_atStamped when a sweep claims the row (flips it to DOWNLOADING); cleared on leaving that state — ages the stale-claim reclaim

The delivery-status ladder

Outbound messages move through QUEUED → SENT → DELIVERED → READ. Meta's status webhooks for a single message can and do arrive out of order (a delivered and a read for the same wamid have been observed racing), so the ladder only ever advances: advanceStatus compares the numeric rank of the incoming status against the row's current status and updates only if the new rank is higher, and only if the row isn't already FAILED. A late delivered arriving after read is a no-op. FAILED is terminal — a failed status webhook always applies regardless of current rank, and once FAILED no later status can move the row.

Idempotency

The Meta message id (wamid, wa_message_id) is a unique key on whatsapp_message. Inbound inserts use INSERT ... ON CONFLICT (wa_message_id) DO NOTHING, so a webhook redelivery, or a replay of the same stored event, is a no-op rather than a duplicate row. The conversation's unread_count and last_message_at/preview are only touched when the insert actually affected a row (insertIgnoringDuplicate returns the row count), so a redelivery never inflates the unread badge or re-rolls the preview forward for a message that was already there.

The 24-hour customer-service window

WhatsApp only allows a free-form reply to a 1:1 conversation within 24 hours of the guest's last inbound message; outside that window only pre-approved templates may be sent. Groups are exempt from this rule entirely. WhatsAppInboxSendService enforces this server-side before writing any row — a rejected send leaves no FAILED noise in the thread — and the thread endpoint (GET .../messages) exposes canSendFreeForm and windowExpiresAt so the UI can grey out the composer before the user even starts typing, rather than only failing on submit. For a group, canSendFreeForm is always true and windowExpiresAt is always null.

Media

Meta's per-message media URLs expire within minutes, so inbound media is pulled into S3 rather than linked directly:

  • On webhook receipt, WhatsAppMediaDownloadService.downloadPending() runs @Async, off the webhook's response path, to store the media before the link expires.
  • A message with media starts media_status = PENDING, moves to DOWNLOADING when a sweep claims it, then STORED on success or FAILED once maxAttempts download attempts are exhausted.
  • Rows are claimed with SELECT ... FOR UPDATE SKIP LOCKED, so the immediate post-webhook download and the periodic retry sweep (WhatsAppMediaRetryJob, every 5 minutes by default) can run concurrently without two sweeps both claiming — and double-downloading — the same row.
  • A claim stranded by a crash or restart (stuck in DOWNLOADING) is reclaimed back to PENDING — or to FAILED once its attempt cap is hit — once it's older than staleClaimMinutes (default 10 minutes).
  • Stored media is served only via a short-lived presigned S3 URL (GET /messages/{id}/media 302s to it, default 15-minute expiry); a message whose media isn't STORED yet returns 404 rather than a broken link.

Announcements and Smart Collect in the thread

Booking announcements (GroupAnnouncementService) and Smart Collect nudges (SmartCollectAnnouncementService) both call WhatsAppGroupService.sendTemplate, and after a successful send both mirror the message into the group's inbox thread via WhatsAppInboxSendService.record(...), writing an outbound row with origin = ANNOUNCEMENT and type = TEMPLATE. This is best-effort: the mirroring call is wrapped so that a failure to record it (a DAO error, a formatting error) is logged and swallowed, never surfaced as an announcement failure — the announcement itself has already been delivered by the time this runs, so it must not be retried or rolled back over a bookkeeping problem. See Booking Announcements for the announcement flow itself.

Endpoints

All under /api/v1/admin/whatsapp (and mirrored at /api/v1/pms/whatsapp):

GET  /conversations                       List conversations (status, channelType, q, limit, offset)
GET /conversations/{id}/messages Thread history (before, limit) + canSendFreeForm/windowExpiresAt
POST /conversations/{id}/messages Send a free-form text reply into a conversation
POST /conversations/{id}/read Mark a conversation read for the whole team
GET /messages/{id}/media 302 redirect to a short-lived presigned media URL
POST /inbox/replay Replay every stored webhook event through ingestion

POST /conversations/{id}/messages currently only sends free-form text ({ "text": "..." }). Sending an approved template from the inbox UI is not built yet — see Deferred below.

The replay tool

POST /inbox/replay (InboxReplayService) walks every row in whatsapp_webhook_event, oldest first, and re-runs it through the same ingestion path a live webhook uses. It has two uses:

  1. Seeding at rollout — every messages-field event captured before this feature existed (raw events were already being persisted for the WhatsApp Groups feature) can be projected into conversations and messages in one pass.
  2. Recovery after an ingestion bug — fix the bug, then replay to backfill whatever the bug caused ingestion to miss or mis-record.

It is idempotent: replaying an event whose wamid is already in whatsapp_message is a no-op via the same ON CONFLICT DO NOTHING, so running it twice, or running it against events that were already ingested live, does not duplicate anything. A row that fails to replay is logged and skipped rather than aborting the whole run.

Running it: call POST /inbox/replay with an authenticated PMS-staff bearer token — same auth as every other endpoint in this doc, no separate credential. Safe to run any time, including while the inbox is in active use: it only reads whatsapp_webhook_event and re-derives local rows, it never calls out to Meta and never sends anything, so it cannot cause a duplicate outbound message. It processes events oldest first in pages of 500 and returns the total number of events processed (not the number of new messages/conversations created — an already-ingested event still counts as "processed"). On a large event table this can take a while; there's no progress indicator, it just returns when the full table has been walked.

Operations

A message's media shows as unavailable (GET /messages/{id}/media 404s, or the UI shows no image/attachment) — check media_status:

media_statusWhat it meansWhat to do
PENDINGNot downloaded yet.Nothing — the immediate post-webhook attempt or the next retry sweep (WhatsAppMediaRetryJob, every 5 minutes by default) will pick it up. Wait and refresh.
DOWNLOADINGA sweep has claimed it and is fetching it right now.Nothing, unless the claim is older than staleClaimMinutes (default 10 min) — the next sweep reclaims and retries it automatically either way.
FAILEDEvery attempt, up to maxAttempts (default 3), has failed. There is no automatic further retry and no manual "retry this media" endpoint.Check application logs around the failed attempts for the message id. Because each attempt is a live call back to Meta keyed on the message's media id (not a cached URL), a FAILED row should be treated as effectively unrecoverable through this system — there is no admin action here that reliably brings the media back. A one-off FAILED is expected loss; a pattern of them points to a real problem (bad WhatsApp API credentials, S3 permission/connectivity issue) worth investigating rather than individually retrying.

History starts at deploy

Meta's Cloud API exposes no endpoint to fetch a conversation's past messages — webhooks are the only way messages ever reach this system. So the inbox's history is bounded by whatever is already sitting in whatsapp_webhook_event: anything Meta delivered before this feature existed (if webhook capture was already running, e.g. for WhatsApp Groups) can be recovered via replay, but anything from before webhook capture existed at all is gone. There is no way to backfill further.

Outstanding verification

Two read-only checks against the production/staging database were called for as part of shipping this feature, to confirm real-world webhook behaviour:

  1. Whether messages-field events are arriving at all (i.e. whether the messages field is actually subscribed on the Meta app) — SELECT event_type, COUNT(*) FROM whatsapp_webhook_event GROUP BY event_type ORDER BY 2 DESC;
  2. Whether group sends ever receive a delivery-status webhook at all — SELECT payload FROM whatsapp_webhook_event WHERE payload::text LIKE '%"statuses"%' ORDER BY received_at DESC LIMIT 3;

Neither check has been run. This document does not assert an answer to either question — do not treat group message ingestion or group delivery receipts as confirmed working until someone with database access runs these two queries. If the first query shows no messages rows, subscribe the field via POST /api/v1/admin/whatsapp/webhook/subscribe and re-check. If the second shows statuses payloads that never carry a group message's wamid, that is an expected Meta limitation, not a bug: group rows will simply stop at SENT and never reach DELIVERED/READ, and no code change is needed for that case.

Deferred

  • Retention policy. Message bodies and media are personal data with no expiry as built. Once a retention period is chosen, this is a scheduled purge job over whatsapp_message (media objects and media_s3_key first, then bodies) — a small follow-up that needs the business decision first.
  • Sending templates from the inbox UI. The { templateName, params } variant of the send endpoint is not built. A 1:1 conversation whose window has closed correctly reports canSendFreeForm=false, but the UI has no template fallback to offer yet.