Writing Realtime & messaging

Prefill is not delivery

A click-to-chat URL opens a composer. It does not prove a message left the device — and a Cloud API 200 only proves accept.

After the quota race, the next quiet lie is “we sent it.”

A green UI often means you built a wa.me link and opened it. The user still has to tap Send. There is no message id on your server. There is no delivery webhook. If they close the chat, your system still thinks it succeeded.

This note is that contract gap. Click-to-chat URLs versus the WhatsApp Business Platform Cloud API — as contracts, not as a vendor pitch. Two offline labs in this repo; both rerun without a Meta account.

What a green “send” button actually did

Deep-link path: concatenate a phone number, encodeURIComponent a string, open https://wa.me/<digits>?text=… (or the api.whatsapp.com/send sibling). Done.

Cloud API path: POST to the Messages API, get a JSON body with a WhatsApp message id (wamid…). Meta’s own docs say that response means the request was accepted, not that the user got the message. Delivery and read arrive later as status webhooks (sent / delivered / read / failed).

Those are two different meanings of “send.” Mixing them in one status column is how you invent false confidence.

Click-to-chat: URL as the only interface

I ran node lab/click-to-chat-url.mjs in this repo on 6 August 2026. It builds both URL shapes, strips the phone to digits, and checks that decode(encode(text)) returns the original.

Selected rows from that run:

CaseRaw textEncoded textwa.me URL lengthRound-trip
plain (em dash)284679ok
? & # in body314578ok
newlines + emoji274780ok
long prefill221622222255 (> 2048)ok

Phones like +62 812-3456-7890 become 6281234567890. Leaving +, spaces, or dashes in the path is a footgun — the lab asserts digits-only.

Encoding is not optional. Raw & or ? inside text= will be read as a new query parameter. The fixture Pay here? amount=10&ref=abc#pay only survives as prefill when encoded (%3F, %26, %23).

After URL build, the lab’s negative contract is blunt:

{
  "messageId": false,
  "deliveryStatus": false,
  "retryKey": false,
  "idempotencyKey": false,
  "userMustTapSend": true
}

Long prefills are a separate trap. At 2255 characters the wa.me URL crossed the lab’s 2k flag. Clients and proxies disagree about how long is too long. If you stuff an invoice into the query string, measure the encoded URL — not the raw string length.

Cloud API: accept ≠ deliver

I did not send a live Cloud API message for this note. No token, no delivery latency numbers invented.

Public docs already draw the line: a successful send response carries a message id; status webhooks are how you learn delivered / read / failed. Outside a customer service window you need an approved template for business-initiated traffic. Free-form service text is for the open window after the user messaged you.

That is enough to choose a contract. It is not enough to paste fake webhook timings into a blog.

Status webhooks need a state machine

Even when you do have Cloud API, “update our row to whatever arrived last” is wrong. Webhooks retry. Events can land out of order. A late delivered after failed should not resurrect a message you already marked dead.

I ran node lab/message-status-machine.mjs — fixture replay only, shaped like status events keyed by message id. Forward-only ranks: sent → delivered → read, with failed terminal. Results from that run:

  • Happy path → final read
  • Duplicate delivered → ignored (reason: "duplicate"); final stays delivered
  • read then late delivered → stays read (reason: "out-of-order")
  • failed then late delivered → stays failed (reason: "terminal-failed")

All four scenarios passed. Rerun anytime; no Meta account required.

What belongs in tests

For click-to-chat:

  • Digits-only phone normalization
  • Encode round-trip for messy copy (emoji, newlines, query-looking characters)
  • Encoded URL length budget you actually tolerate
  • UI copy that does not say “Sent” after window.open

For Cloud API:

  • Persist wamid from the accept response before you claim anything downstream
  • Idempotent, forward-only status updates (the fixture lab above)
  • Template vs service-window branching — do not treat both as the same send helper

Do not write a test that asserts “message delivered” after constructing a URL. That assertion can never be true on that path.

Choosing the contract

Use click-to-chat when a human finishing the send is acceptable, and your system only needs “we handed them a composer.” Use the Cloud API when you need a message id, retries you control, and status you can store.

Same chat app on the user’s phone. Different promises to your database. If your dashboard says Delivered for a deep link, the bug is the label — not WhatsApp.

A public notebook

Notes from real delivery: racey quotas, sync when a device is offline, messaging APIs, and the gap between a clean local demo and production.

Not a product catalog, a tutorial syllabus, or a course funnel. If a post names a tool, I used it. If it describes a failure, it happened.

About Contact