At Selenios, candidates can apply to a job without leaving WhatsApp. They message the company's number, accept the privacy notice, and get a native form inside the chat: a WhatsApp Flow. They fill it in and tap send. We generate one Flow per vacancy from the application form the recruiter built in our platform, publish it through Meta's API, and cache it until the form changes.
Last week a client sent us a screen recording. On one vacancy, every candidate who tapped "Completar" got a spinner, then a grey sheet that said "Se produjo un error. Vuelve a intentarlo más tarde." Something went wrong, try again later. Other vacancies from the same client, on the same phone number, worked.
Meta's API reported the Flow as published, with no validation errors and green health checks. It was still broken for every candidate who opened it. This post is about how we found the cause, and what we built so the next one of these reaches us before a client's screen recording does.
Nothing on our side
The first stop was our own logs. At the minute of the recording, the conversation looked normal. The webhook received the candidate's message, we parsed the vacancy's form into eleven fields, found a Flow already published for that exact form, and sent it. No errors, no warnings, nothing in Sentry.
That silence was expected. Our Flows are static: the message opens the first screen with flow_action: "navigate", and every screen is defined in the Flow JSON itself. There is no data endpoint, so opening the form makes no request to our backend. If the Flow breaks while opening, it breaks on the phone, and nothing reaches us.
Everything green on Meta's side
The next suspect was the Flow itself, so I asked Meta about it (we go through Kapso, which proxies the Graph API):
GET /{flow-id}?fields=status,validation_errors,health_status
{
"status": "PUBLISHED",
"validation_errors": [],
"json_version": "7.0",
"health_status": {
"can_send_message": "AVAILABLE",
"entities": [
{ "entity_type": "FLOW", "can_send_message": "AVAILABLE" },
{ "entity_type": "WABA", "can_send_message": "AVAILABLE" },
{ "entity_type": "BUSINESS", "can_send_message": "AVAILABLE" },
{ "entity_type": "APP", "can_send_message": "AVAILABLE" }
]
}
}
Published, no validation errors, and every entity able to send. A Flow from a vacancy that worked returned the same response. On this evidence the broken Flow was healthy.
That is the first thing worth knowing about WhatsApp Flows. health_status answers can this business send this Flow?: the account's standing, the phone number, the app. It does not tell you whether the Flow can be rendered. validation_errors is whatever the publish-time validator caught, and, as it turned out, the validator does not catch everything the phone checks.
Reading the JSON Meta actually serves
Meta lets you download the published Flow JSON: GET /{flow-id}/assets returns a signed download_url. I downloaded the broken Flow and a working one and compared them component by component.
The difference was in a Dropdown, the one asking where the candidate lives. Abridged:
{
"type": "Dropdown",
"name": "lugar_de_residencia",
"data-source": [
{ "id": "Norte", "title": "Norte" },
{ "id": "Centro", "title": "Centro" },
{ "id": "Sur", "title": "Sur" },
{ "id": "Norte", "title": "Norte (4)" },
{ "id": "Centro", "title": "Centro (5)" },
{ "id": "Sur", "title": "Sur (6)" },
...
]
}
(Branch names changed. The real list had 24 options.) Three of them appeared twice in the recruiter's form, most likely from pasting a list over one that was already there. On the web form a repeated option is cosmetic: the candidate sees the same branch twice and picks either. Our Flow generator already had a rule for long options that truncate to the same visible title: it appends a counter so the candidate can tell them apart. That rule changed the titles and left the ids alone, because the id is the value we validate the answer against. So the Dropdown went out with three repeated ids.
Meta's validator accepted it and published it. The WhatsApp client rejected it as soon as it tried to render the screen.
At first this was only an inference: the repeated ids were the only anomaly between the broken Flow and the ones that worked, and no other published Flow of ours had them. Since we send everything through Kapso, I also wanted to rule out the proxy. Two read-only checks settled both questions. First, the Flow has no endpoint_uri, so opening it calls neither Kapso nor us; the phone downloads the JSON from Meta's CDN and renders it. Second, the Graph API returns a preview URL that renders the Flow inside Meta's own Business Manager. The working Flow opened normally there. The broken one opened to this:
Component Validation Error
Dropdown 'lugar_de_residencia' has duplicate ID's.
So Meta's renderer knows the rule, with a precise message for it. The validator that answered PUBLISHED with validation_errors: [] does not apply it. And the candidate on the phone never saw the precise message, only "something went wrong".
The fix, and why a bigger version number matters
The fix in the generator is one line: drop repeated option values and keep the first occurrence. Both copies carry the same value, so nothing is lost.
On its own, that line would not have repaired the broken vacancy. We cache each published Flow under a hash of its inputs: the fields, the labels, the language, and a GENERATOR_VERSION constant. The recruiter's form had not changed, so the hash had not changed, and the broken Flow would have kept being served forever. Bumping the generator version changes every hash, so every cached Flow republishes once, on its next conversation. The vacancy gets repaired on deploy without anyone editing its form.
Then we closed the door behind it:
- The form editor marks a repeated option inline and will not save until it is removed, and pasting a list skips the options that are already there.
- The backend rejects a form with a repeated option, trimmed and case-insensitive, with a message that names the option. Before turning it on we counted the stored forms it would reject in production: one. No legacy data gets locked.
Our own checks, before Meta's
The part I care most about is the monitoring. The underlying problem is general: Meta's publish-time validator and the WhatsApp client do not enforce the same rules. A Flow can be PUBLISHED and unusable, and with a static Flow the failure happens on the phone, where we cannot see it.
So every publish now runs our own lint over the generated JSON before it goes to Meta. It looks for problems a validator lets through and a renderer may refuse: a repeated id in any data-source, a repeated component name inside a screen. It does not block the publish, it reports. Any hit becomes a Sentry warning with the client and the screening, and a form whose source data had repeated options gets its own warning naming the fields, even though the generator now cleans them up. Both run once per publish, not once per conversation, so a bad form shows up once instead of once per candidate.
The list of checks is short today. It will grow as we learn what else passes the validator and fails on the phone.
What I take from it
- "Published" means the validator accepted it, not that it works. Treat a green status on generated content as one signal, not a test.
- Know what a health check covers.
can_send_messageis about the account's standing. It says nothing about the content you are sending. - Static means no telemetry. If nothing reaches your server when the user opens the thing, you only learn about failures from users. Either validate harder before publishing or add a data endpoint that gives you a signal.
- Read the artifact that is actually served. Our logs, our database and Meta's status endpoint all looked right. The bug was only visible in the JSON Meta serves, two API calls away.
- Version your generator. If you cache generated output under a content hash, a fix to the generator does not reach what is already cached unless the version is part of the hash.
And one thing I would ask Meta for: make the publish-time validator check what the client checks. Its own preview already says "Dropdown has duplicate ID's". A Flow that no phone can render should not come back PUBLISHED.