Question Types
Fly supports the following question types. Note that for some types, you must add JSON to the “description” in Typeform. This JSON tells the Fly chatbot how to treat the question, sometimes overriding part or everything in Typeform itself for that question.
This is a free text question. The user can type anything and send it in the chat and it will be accepted as valid.
In Typeform, pick “Short Text”
Creates a multiple choice question.
In Typeform, pick “Multiple Choice”.
Notes:
- You can have a maximum of 13 answers.
- If any answer text is longer than 15 characters, you should use letters A,B,C…M as the answers instead and the question text should be written in the following format:
Which region do you live in?
-A. North Central (Middle Belt)
-B. North East
-C. North West
-D. South East
-E. South South (Niger Delta)
-F. South West
The - and the . before and after the letters are optional, but recommended for legibility.
Button Choice is similar to Multiple Choice, but displays options as persistent buttons instead of quick replies. Quick replies disappear after the user taps one, while button template buttons remain visible in the chat.
In Typeform, pick “Multiple Choice”, then add the following to the description:
{"type": "button_choice"}
Limitations:
- Maximum of 3 buttons (Facebook API limit). Use Multiple Choice for more options.
- Button titles are truncated at 20 characters by Facebook.
- Template text is limited to 640 characters.
When to use Button Choice:
- For questions with 3 or fewer options where you want buttons to persist visually
- Yes/No/Maybe style questions with a button appearance
- When you want the visual distinction of button templates over quick replies
Example Setup:
Create a Multiple Choice question in Typeform with your options (up to 3), then add to the description:
{"type": "button_choice"}
The bot will display a button template with postback buttons instead of quick replies.
Number type validates that the user has sent us a number and only a number. To change the error message when a user enters something other than a number, do: ….
In Typeform, pick “Number”.
A statement is a simple message that you send. The bot will move on to the next question without waiting for a response.
In Typeform, pick “Statement”
For an image you can use a URL:
JSON:
{"type": "attachment",
"keepMoving": true,
"attachment": {
"type": "image",
"url": "https://i.imgur.com/ZSHauqq.png"
}
}
Or, to be more performant, you can pre-upload it to Facebook and then use it as an attachment id:
{"type": "attachment",
"keepMoving": true,
"attachment": {
"type": "image",
"attachment_id": "3656576331230635"
}
}
JSON:
{"type": "attachment",
"keepMoving": true,
"attachment": {
"type": "video",
"url": "https://url-to-your-video.mp4"
}
}
The “Upload” type allows users to send attachments, such as images or files. Use the attachment type (“image” for pictures) to specify what kind of file you would like the user to provide. The response in the exported dataset will equal a facebook URL to the file for download.
JSON:
{"type": "upload",
"upload": {
"type": "image"
}
}
It’s possible to send a link as a button and allow the users to open it in a Messenger webview:
JSON:
{
"type": "webview",
"url": "https://asiapacific.unwomen.org/en/countries/india",
"buttonText": "Visit UN Women",
"extensions": false,
"keepMoving": true
}
If you want to go further, Fly offers the ability to track link clicking. That can be done with the following snippet:
JSON:
{
"type": "webview",
"url": {
"base": "links.vlab.digital",
"params": {
"url": "asiapacific.unwomen.org/en/countries/india",
"id": "{{hidden:id}}",
"pageid": "YOUR_PAGE_ID"
}
},
"buttonText": "Visit UN Women",
"extensions": false,
"keepMoving": true
}
To track specific information about the user, add metadata as query parameters in addition to url in the url value. For example, you can add the user id by adding id with a typeform ref to a “hidden field” called “id” (note, you will probably need to type out the “@id” part inside Typeform so it links it to a hidden field properly):
{
"type": "webview",
"url": {
"base": "links.vlab.digital",
"params": {
"url": "asiapacific.unwomen.org/en/countries/india",
"id": "{{hidden:id}}",
"pageid": "YOUR_PAGE_ID"
}
},
"buttonText": "Visit UN Women",
"extensions": false,
"keepMoving": true
}
Set keepMoving to true if you want the message to act like a “statement” and continue to the next message. Otherwise, you can combine with a “wait” to wait until the user has clicked the link:
JSON:
{
"type": "webview",
"url": {
"base": "links.vlab.digital",
"params": {
"url": "asiapacific.unwomen.org/en/countries/india",
"id": "{{hidden:id}}",
"pageid": "YOUR_PAGE_ID"
}
},
"buttonText": "Visit UN Women",
"responseMessage": "Click on the button to visit the website",
"extensions": false,
"wait": {
"type": "external",
"value": {
"type": "linksniffer:click",
"url": "https://asiapacific.unwomen.org"
}
}
}
You can set extensions to true if the page you are visiting is using Messenger Extensions (i.e. Fly Survey’s “Moviehouse” which can be used to watch Vimeo videos and track video-watching events:
Moviehouse example, which waits until they either start watching the video OR one hour is passed:
{
"type": "webview",
"url": {
"base": "virtuallab-videos.netlify.app",
"params": {
"id": "YOUR_VIMEO_ID",
"pageId": "YOUR_FACEBOOK_PAGE_ID",
"userId": "{{hidden:id}}"
}
},
"buttonText": "Watch now!",
"responseMessage": "Sorry, you need to watch the video before continuing",
"extensions": true,
"wait": {
"op": "or",
"vars": [
{
"type": "timeout",
"value": "1 hour"
},
{
"type": "external",
"value": {
"type": "moviehouse:play",
"id": "YOUR_VIMEO_ID"
}
}
]
}
}
In addition to HTTP and HTTPS links, linksniffer supports special URI schemes like tel:, mailto:, sms:, and others. This allows you to create buttons that trigger phone calls, compose emails, or send SMS messages directly from your chatbot.
The linksniffer service accepts an optional p (protocol) parameter to specify which URI scheme to use:
https(default) - Standard web linkshttp- Non-secure web linkstel- Phone number links (triggers phone dialer)mailto- Email links (opens email client)sms- SMS links (opens messaging app)- Other single-colon schemes - Any valid URI scheme (e.g.,
whatsapp:)
Protocol formatting:
- HTTP and HTTPS use
://separator:https://example.com - All other schemes use
:separator:tel:+1234567890,mailto:user@example.com
To create a button that opens the phone dialer:
{
"type": "webview",
"url": {
"base": "links.vlab.digital",
"params": {
"url": "+1-555-123-4567",
"p": "tel",
"id": "{{hidden:id}}",
"pageid": "YOUR_PAGE_ID"
}
},
"buttonText": "Call Support",
"extensions": false,
"keepMoving": true
}
This will generate a link like tel:+1-555-123-4567 that opens the device’s phone dialer when clicked. The click is still tracked with the user’s id and pageid for analytics.
To create a button that opens the email client:
{
"type": "webview",
"url": {
"base": "links.vlab.digital",
"params": {
"url": "support@example.com",
"p": "mailto",
"id": "{{hidden:id}}",
"pageid": "YOUR_PAGE_ID"
}
},
"buttonText": "Email Us",
"extensions": false,
"keepMoving": true
}
This generates mailto:support@example.com and tracks when users click the email button.
To create a button that opens the messaging app:
{
"type": "webview",
"url": {
"base": "links.vlab.digital",
"params": {
"url": "+1-555-123-4567",
"p": "sms",
"id": "{{hidden:id}}",
"pageid": "YOUR_PAGE_ID"
}
},
"buttonText": "Text Us",
"extensions": false,
"keepMoving": true
}
You can use wait conditions with special URI schemes just like regular links:
{
"type": "webview",
"url": {
"base": "links.vlab.digital",
"params": {
"url": "+1-555-123-4567",
"p": "tel",
"id": "{{hidden:id}}",
"pageid": "YOUR_PAGE_ID"
}
},
"buttonText": "Call Support",
"responseMessage": "Please call us to continue",
"extensions": false,
"wait": {
"type": "external",
"value": {
"type": "linksniffer:click"
}
}
}
Note: All link clicks through linksniffer are tracked and generate events, regardless of the protocol used. This allows you to track when users initiate phone calls, compose emails, or perform other actions triggered by special URI schemes.
When stitching from one form to another, the “stitch” must be a statement:
JSON:
{"type": "stitch",
"stitch": { "form": "FORM_SHORTCODE" }}
Where FORM_SHORTCODE is the shortcode of the form you’d like to move to.
You can also include metadata:
JSON:
{"type": "stitch",
"stitch": { "form": "FORM_SHORTCODE", metadata: {"variable_name": "variable_answer" }}}
Timeouts allow you to pause your survey and continue it later, creating multi-wave surveys. See more under Timeouts.
You can wait on external events. For example, linksniffer events allow you to wait until someone clicks a link. Or MovieHouse events allow you to wait until someone has started (or finished) a video.
For example, this JSON would wait for a moviehouse:play event with the video id 23456:
{ "type": "wait",
"wait": {
"type": "external",
"value": {
"type": "moviehouse:play",
"id": "23456"
}}
}
If you want to wait for any moviehouse:play event, regardless of video id, you can simply omit part of the json from the “value” property:
{ "type": "wait",
"wait": {
"type": "external",
"value": {
"type": "moviehouse:play"
}}
}
For more complex scenarios, you can use logical operators (op) and variables (vars) to create compound wait conditions. This allows you to wait for multiple events or create sophisticated logic.
and: All conditions must be fulfilledor: At least one condition must be fulfilled
Wait for both a link click AND a video play event:
{
"type": "wait",
"wait": {
"type": "external",
"op": "and",
"vars": [
{
"type": "external",
"value": {
"type": "linksniffer:click",
"url": "https://example.com"
}
},
{
"type": "external",
"value": {
"type": "moviehouse:play",
"id": "12345"
}
}
]
}
}
Wait for either a link click OR a video play event:
{
"type": "wait",
"wait": {
"type": "external",
"op": "or",
"vars": [
{
"type": "external",
"value": {
"type": "linksniffer:click",
"url": "https://example.com"
}
},
{
"type": "external",
"value": {
"type": "moviehouse:play",
"id": "12345"
}
}
]
}
}
You can create more complex nested conditions. For example, wait for (link click AND video start) OR (link click AND video finish):
{
"type": "wait",
"wait": {
"type": "external",
"op": "or",
"vars": [
{
"type": "external",
"op": "and",
"vars": [
{
"type": "external",
"value": {
"type": "linksniffer:click",
"url": "https://example.com"
}
},
{
"type": "external",
"value": {
"type": "moviehouse:play",
"id": "12345"
}
}
]
},
{
"type": "external",
"op": "and",
"vars": [
{
"type": "external",
"value": {
"type": "linksniffer:click",
"url": "https://example.com"
}
},
{
"type": "external",
"value": {
"type": "moviehouse:finish",
"id": "12345"
}
}
]
}
]
}
}
You can combine different types of events in the same logical expression:
{
"type": "wait",
"wait": {
"type": "external",
"op": "and",
"vars": [
{
"type": "external",
"value": {
"type": "linksniffer:click",
"url": "https://example.com"
}
},
{
"type": "timeout",
"value": "5m"
}
]
}
}
This example waits for both a link click AND a 5-minute timeout to occur.
When using operators, the wait object should have this structure:
{
"type": "wait",
"wait": {
"type": "external",
"op": "and|or",
"vars": [
// Array of wait conditions (can be simple or complex)
]
}
}
Each item in the vars array can be:
- A simple wait condition (with
typeandvalue) - Another complex condition (with
opandvarsfor nesting)
This allows you to create arbitrarily complex logical expressions for your wait conditions.
Sends a pre-approved Facebook Utility Message template to the user. Use this for notifications that need to reach the user after the 24-hour Messenger window has closed — survey results, prize notifications, reminders.
Utility Messages are the current replacement for the deprecated Message Tags (
CONFIRMED_EVENT_UPDATE, etc.) and Recurring Notifications. They require no user opt-in but do require a template that Facebook has approved for your Page.
Go to Message Templates in the dashboard. A template is identified by the tuple (page, name, language) — the same template name can exist in multiple independently-approved language variants, each created and approved separately.
| Field | Constraint |
|---|---|
| Name | snake_case — lowercase letters, digits, underscores. Unique per (page, language). |
| Language | Must be an exact Facebook-supported locale (en_US, es_LA, ha, …). |
| Body | Up to 1024 characters. Use {{1}}, {{2}}, … for positional placeholders. Numbering must start at {{1}} and be sequential. |
| Buttons | Optional. Up to 3 buttons. Each label is ≤ 20 characters and unique within the template. Buttons are POSTBACK buttons — they stay visible in the chat until the user taps one. |
If your body contains placeholders, the dashboard will prompt for a sample value per placeholder. Facebook reviewers see the body with these values substituted in — that’s how they judge whether the content is truly utility and not promotional. Samples are not used at send time; real values come from params in your survey JSON.
Approved templates can’t be edited. To change wording or buttons, delete and recreate. Approval is usually fast (seconds), but Facebook may reject content it considers promotional.
- Text-only template → use a Statement question.
- Template with buttons → use a Multiple Choice question whose choices match the template’s approved button labels (same labels, same order, same count). Typeform’s native logic editor reads those choices to drive branching.
Put the utility-message metadata in the question’s description as JSON (or YAML — both work):
{
"type": "utility_message",
"template": "results_ready",
"language": "en_US",
"params": ["{{hidden:name}}", "$5"]
}
Fields:
| Field | Required | Notes |
|---|---|---|
type | Yes | Always utility_message. |
template | Yes | The template name you created in the dashboard. |
language | Yes | The exact locale of the approved variant (e.g. en_US, es_LA, ha). No silent default — missing language is an error. |
params | Only if the body has placeholders | Positional array of values substituted into {{1}}, {{2}}, …. Supports {{hidden:X}} interpolation. Length must equal the placeholder count exactly. |
Define the Multiple Choice’s choice labels to exactly match the approved template’s button labels — same labels, same order, same count. The approved template bakes in value == label per button, so the value Typeform’s logic editor sees on a tap is the same string as the label the user pressed:
[Multiple Choice question] description: {"type": "utility_message", "template": "results_ready", ...}
choices: [ "Yes", "No" ]
↓
logic jump:
if answer equals "Yes" → [show results]
if answer equals "No" → [thank and end]
If the choice labels don’t match, or the count differs from the template’s button count, the send will fail — Facebook validates the parameter count against the approved button count.
Utility messages are usually sent after a long-running Wait — that’s when the 24-hour window matters:
[Consent questions]
↓
[Wait - timeout: 3 days]
↓
[Utility Message] "Your {{1}} results are in, {{2}}!" ← sent outside the 24h window
See Timeouts for timeout setup.
| What you see | What it usually means |
|---|---|
Template stays PENDING indefinitely | Refresh the page; if still stuck after an hour, delete and recreate. |
Rejected: “promotional” / TAG_SHOULD_BE_MARKETING | Facebook classified the body as marketing. Rewrite to be transactional — confirmations, reminders, results — and remove calls-to-action like “Claim now!”. |
Rejected: TEMPLATE_VARIABLES_MISSING_SAMPLE_VALUES | A {{N}} placeholder in the body has no sample value. Provide one in the dashboard form, or remove the placeholder. |
| Send fails with “template not found” | The (template, language) pair in your survey JSON doesn’t match any APPROVED row. Check spelling and create the missing language variant. |
| Send fails with “placeholder count mismatch” | The params array length doesn’t match the number of {{N}} placeholders in the body. Count and align. |
| Button-tap question has fewer/more choices than the approved template | The Multiple Choice’s choices count must equal the approved button count exactly. |
Read about payment question types under Incentive Payments
To pass thread control to another app, tag your final question with the following:
{
"handoff": {
"target_app_id": "123456789",
"metadata": { "return_app_id": "987765432" }
}
}
If you would like to pass control, then wait for it to come back before proceeding to the next question, you can combine this with a wait event:
{
"handoff": {
"target_app_id": "123456789",
"metadata": { "return_app_id": "987765432" }
}
"wait": {
"type": "handover",
"value": { "target_app_id": "123456789" }
}
}