Skip to content

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:

json
{"area":"ุงู„ุฒู‡ุฑุงุก","budget":"400","purpose":"buy","property_type":"ุงุฑุถ ","flow_token":"flow_1935445183710859"}

Every field was wrong for its destination:

Flow sentColumnWhy 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 โ€‹

ArtefactPath
Screen definitions (reviewable source)supabase/flows/<slug>.json
Value vocabularies + response mapperssupabase/functions/_shared/whatsappFlowContract.ts
Drift guardtool/check_whatsapp_flow_contract.mjs
Contract unit testssupabase/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 rowspublic.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 โ€‹

SlugLineEntityLands in
property_request_ar+965 2246 6861aldilaijanrequest_suggestions โ†’ requests via decide_request_suggestion
property_submission_ar+965 2246 6861aldilaijanproperty_submissions โ†’ properties_base via trg_transfer_approved_submission
valuation_request_ar+965 2247 6375khobaravaluation_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 โ€‹

  1. Edit supabase/flows/<slug>.json. Every option id must be a canonical key from whatsappFlowContract.ts โ€” Arabic belongs in title, never in id.
  2. If you add a field, add it to the matching mapper in the contract module and to MAPPER_FIELDS in the checker. A field that no mapper reads is a field the customer fills in and we discard.
  3. Run the guards:
    bash
    node tool/check_whatsapp_flow_contract.mjs
    bash
    deno test --allow-env supabase/functions/_shared/whatsappFlowContract.test.ts
  4. 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).
  5. Record the id on the whatsapp_flows row so the submission handler can link whatsapp_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:

  1. 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.pem
    bash
    openssl rsa -in flow-private.pem -pubout -out flow-public.pem
  2. Store the private half as the WHATSAPP_FLOW_PRIVATE_KEY Supabase secret. Remember a new secret does not reach already-warm isolates โ€” redeploy the slug after setting it.
  3. Upload the public half to the WABA (POST /<phone-number-id>/whatsapp_business_encryption).
  4. Point the flow's endpoint URI at https://<project>.supabase.co/functions/v1/whatsapp-flow-data and run Meta's health check โ€” it sends action: "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:

sql
select response_data, request_created, request_id
from whatsapp_flow_submissions order by created_at desc limit 3;
sql
-- 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;
sql
-- 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:

sql
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_id say what the webhook staged the answer into (request_suggestions, valuation_requests or property_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_id is a real foreign key to requests. It stays NULL until staff approve the staged suggestion; a trigger on request_suggestions then sets request_id and request_created = true. Valuation and property-submission flows never create a request, so they keep request_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:

sql
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.

Aldilaijan & Khobara Real Estate Platform