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:
| Column | Meaning |
|---|---|
id | Row id |
event_type | Best-effort classification: the inner group type if present, else the change field (e.g. messages, group_create) |
payload | The full webhook body, verbatim, as JSONB |
received_at | When 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:
| Column | Meaning |
|---|---|
id | Conversation id |
channel_type | GROUP | DIRECT |
group_id | Meta group id; null for DIRECT |
wa_user | Counterparty phone number; null for GROUP |
phone_number_id | Our WABA phone-number-id, so a second business number never collides with an existing thread |
title | Display name/subject |
last_message_at | Rolled 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_preview | Overwritten 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_count | Team-shared unread counter; bumped only on a genuinely new inbound message |
last_read_at | When the thread was last marked read |
status | OPEN | ARCHIVED |
whatsapp_message — one row per message, either direction:
| Column | Meaning |
|---|---|
id | Message id |
conversation_id | FK → whatsapp_conversation |
wa_message_id | Meta's wamid; unique, and the idempotency key for inbound inserts. Null on an outbound row until the send returns |
direction | INBOUND | OUTBOUND |
sender_wa_user / sender_name | Set for inbound; Meta's contact profile name when it sent one |
type | TEXT | IMAGE | DOCUMENT | AUDIO | VIDEO | STICKER | SYSTEM | UNSUPPORTED |
body | Text body, or the media caption |
context_wa_message_id | The wamid this message is a WhatsApp-level reply to, if any |
origin | INBOX (a reply typed by staff) | ANNOUNCEMENT | TEMPLATE — see Booking Announcements |
sent_by_user_id | Staff user who sent it, for an INBOX outbound row |
status | Inbound: RECEIVED. Outbound: QUEUED → SENT → DELIVERED → READ, or FAILED |
error_code / error_message | Set when a send or a status webhook reports failure |
sent_at / delivered_at / read_at | Timestamps for each rung of the status ladder |
occurred_at | Meta'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_key | Media metadata and, once stored, the S3 object key |
media_status | PENDING | DOWNLOADING | STORED | FAILED; null when the message has no media |
media_attempts | Download attempts made so far, capped at maxAttempts |
media_claimed_at | Stamped 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 toDOWNLOADINGwhen a sweep claims it, thenSTOREDon success orFAILEDoncemaxAttemptsdownload 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 toPENDING— or toFAILEDonce its attempt cap is hit — once it's older thanstaleClaimMinutes(default 10 minutes). - Stored media is served only via a short-lived presigned S3 URL (
GET /messages/{id}/media302s to it, default 15-minute expiry); a message whose media isn'tSTOREDyet 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:
- 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. - 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_status | What it means | What to do |
|---|---|---|
PENDING | Not 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. |
DOWNLOADING | A 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. |
FAILED | Every 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:
- Whether
messages-field events are arriving at all (i.e. whether themessagesfield is actually subscribed on the Meta app) —SELECT event_type, COUNT(*) FROM whatsapp_webhook_event GROUP BY event_type ORDER BY 2 DESC; - 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 andmedia_s3_keyfirst, 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 reportscanSendFreeForm=false, but the UI has no template fallback to offer yet.