WhatsApp Flows โ setup and field contract โ
Referenced from supabase/functions/whatsapp-webhook/index.ts. Previously this file did not exist, which is a fair summary of how the first flow was managed.
Why this document exists โ
property_request_ar was authored directly in Meta's console. Nothing about it was in the repo โ not the screens, not the field names, not the option values โ so it drifted from the columns it writes into and nobody could see it happen. The single submission it ever received, in full:
{"area":"ุงูุฒูุฑุงุก","budget":"400","purpose":"buy","property_type":"ุงุฑุถ ","flow_token":"flow_1935445183710859"}Every field was wrong for its destination:
| Flow sent | Column | Why it failed |
|---|---|---|
purpose: "buy" | requests.purpose (then al_gharad) | purpose is the intended use (residential/investment), not the transaction. The webhook mapped it to the Arabic verb ุดุฑุงุก, which matches no option in the app's picker. |
property_type: "ุงุฑุถ " | requests.description (then al_wasf) | description holds the property class (residential_privateโฆ). A property type belongs in property_types text[]. |
area: "ุงูุฒูุฑุงุก" | requests.target_areas (then al_manatiq) | text[] of paci_areas.area_key (zahra), not a scalar Arabic display name. |
budget: "400" | requests.budget (then al_mizaniya) | Numeric KWD with no unit stated anywhere in the form. |
Result: request_suggestions accumulated 9 rows and zero were ever promoted, and all 358 rows in requests still have source_channel IS NULL.
Where things live now โ
| Artefact | Path |
|---|---|
| Screen definitions (reviewable source) | supabase/flows/<slug>.json |
| Value vocabularies + response mappers | supabase/functions/_shared/whatsappFlowContract.ts |
| Drift guard | tool/check_whatsapp_flow_contract.mjs |
| Contract unit tests | supabase/functions/_shared/whatsappFlowContract.test.ts |
data_exchange endpoint (area cascade) | supabase/functions/whatsapp-flow-data/ |
Inbound routing (nfm_reply) | supabase/functions/whatsapp-webhook/index.ts |
| Registry rows | public.whatsapp_flows |
Meta remains the runtime source for a published flow. whatsapp_flows.flow_json is deliberately left empty rather than holding a copy that nothing keeps in step.
The three flows โ
| Slug | Line | Entity | Lands in |
|---|---|---|---|
property_request_ar | +965 2246 6861 | aldilaijan | request_suggestions โ requests via decide_request_suggestion |
property_submission_ar | +965 2246 6861 | aldilaijan | property_submissions โ properties_base via trg_transfer_approved_submission |
valuation_request_ar | +965 2247 6375 | khobara | valuation_requests + valuations via submit_public_valuation_request |
Line ownership is enforced in code, not by convention: resolveFlowForEntity() refuses to send a flow from a line that does not own it, and the nfm_reply handler refuses to route a submission whose flow_token names a flow this line does not own. A valuation form offered from the brokerage number would reply from the wrong sender and file the request under the wrong inbox.
Adding or changing a flow โ
- Edit
supabase/flows/<slug>.json. Every optionidmust be a canonical key fromwhatsappFlowContract.tsโ Arabic belongs intitle, never inid. - If you add a field, add it to the matching mapper in the contract module and to
MAPPER_FIELDSin the checker. A field that no mapper reads is a field the customer fills in and we discard. - Run the guards:bash
node tool/check_whatsapp_flow_contract.mjsbashdeno test --allow-env supabase/functions/_shared/whatsappFlowContract.test.ts - Paste the JSON into the Meta Flow builder, publish, and put the returned flow id in the env var named by
metaFlowIdEnv(WHATSAPP_REQUEST_FLOW_ID,WHATSAPP_VALUATION_FLOW_ID,WHATSAPP_SUBMISSION_FLOW_ID). - Record the id on the
whatsapp_flowsrow so the submission handler can linkwhatsapp_flow_submissions.flow_id.
The data_exchange endpoint โ
Kuwait has 221 areas in paci_areas โ more than a static Flow dropdown can carry โ and the brokerage request form narrows them further by area_property_class. So all three flows ask for a governorate first and call whatsapp-flow-data to fetch that governorate's areas.
Meta encrypts these calls. Setup:
- Generate an unencrypted PKCS#8 key pair (WebCrypto cannot open a passphrase-protected PEM):bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out flow-private.pembashopenssl rsa -in flow-private.pem -pubout -out flow-public.pem - Store the private half as the
WHATSAPP_FLOW_PRIVATE_KEYSupabase secret. Remember a new secret does not reach already-warm isolates โ redeploy the slug after setting it. - Upload the public half to the WABA (
POST /<phone-number-id>/whatsapp_business_encryption). - Point the flow's endpoint URI at
https://<project>.supabase.co/functions/v1/whatsapp-flow-dataand run Meta's health check โ it sendsaction: "ping", which the function answers with{"status":"active"}.
An undecryptable request is answered with 421, which tells Meta to re-negotiate rather than marking the endpoint permanently broken.
Verifying a submission actually landed โ
A 200 from Meta proves delivery, not a write. Assert on rows:
select response_data, request_created, request_id
from whatsapp_flow_submissions order by created_at desc limit 3;-- A flow submission lands in request_suggestions (pending) first; it only
-- becomes a requests row once staff approve it (decide_request_suggestion).
select purpose, transaction_type, property_types, target_areas, description,
budget, status, source_channel, created_request_id
from request_suggestions where source_channel = 'whatsapp_flow'
order by created_at desc limit 1;-- After approval:
select purpose, transaction_type, property_types, target_areas, price_groups,
description_unresolved, source_channel
from requests where source_channel = 'whatsapp_flow'
order by created_at desc limit 1;purpose must be residential, investment, or NULL โ never ุดุฑุงุก. target_areas must be area keys. description is the property class (residential_private, โฆ). description_unresolved must be NULL.
Tracing one submission end to end:
select s.created_at, s.entity, s.staged_table, s.staged_id,
s.request_created, s.request_id
from whatsapp_flow_submissions s order by s.created_at desc limit 3;staged_table/staged_idsay what the webhook staged the answer into (request_suggestions,valuation_requestsorproperty_submissions). A routed submission always has them; a NULL pair means the answer was stored but not routed (unknown token, wrong line, or nothing worth staging).request_idis a real foreign key torequests. It stays NULL until staff approve the staged suggestion; a trigger onrequest_suggestionsthen setsrequest_idandrequest_created = true. Valuation and property-submission flows never create a request, so they keeprequest_created = false.
A sent flow can also fail after Meta accepted it. The failure arrives later as a status webhook and is kept in whatsapp_webhook_events.status_errors:
select created_at, entity, status_errors
from whatsapp_webhook_events
where status_errors is not null order by created_at desc limit 5;131047 ("Re-engagement message") means the recipient had not written to that line in the last 24 hours: a free-form flow, like any free-form message, is only delivered inside that window.
