Developer Reference

This page is for developers who connect Omniform to their own systems. You may need to build two endpoints:

  • A choices endpoint that returns the choices for a dropdown, if your admin loads choices from your API.

  • A webhook that receives each form submission.

How admins set up forms is described in Build a Form.

To let customers fill a form from a link, your system creates the link with the Form Links API. Its documentation is linked as Public API Documentation at the top of the API Tokens menu in Omniform settings. Submissions from a link arrive at the same webhook described below, with a few extra keys.

Choices endpoint

The Nested API Dropdown, and Dropdown and Multi-select with From API as their choices source, load their choices from an endpoint you provide.

Response format

Return a JSON array of objects with label and value:

[
{ "label": "Option 1", "value": "option_1" },
{ "label": "Option 2", "value": "option_2" },
{ "label": "Option 3", "value": "option_3" }
]
  • label is the text the agent sees.

  • value is what your webhook receives for that choice, for example an ID from your system.

  • Return the array itself, not an object that wraps it (such as { "data": [...] }). Other shapes are not displayed correctly.

  • For the Nested API Dropdown, if the request fails or the response is not valid JSON, the agent sees Failed to get options.

For Dropdown and Multi-select with From API as their choices source:

  • label and value may be text or numbers. value is always sent to your webhook as text.

  • Items with the same value are shown once. Items with an empty value are skipped.

  • If the request fails, or the response is not an array of label/value objects, the agent sees Couldn't load choices. Close and reopen this list to try again. They can still remove choices they already selected and submit the form.

  • If the response is an empty array, the list shows that there is no data.

  • A choice the agent selected earlier that your endpoint no longer returns stays in the field, marked Not in list. It is still sent to your webhook unless the agent removes it, and it counts toward the Choice Limit.

How the request is sent

  • Method: GET, with the headers and query parameters configured on the field.

  • Where the request comes from depends on who fills the form. In the agent widget, it is sent from the agent's browser (see the next points). On the customer page, it is sent from Omniform's servers, so the customer never sees the endpoint, headers, or query parameters.

  • Added automatically: the query parameters room_id (the room the agent is working in) and app_id (your Omnichannel App ID). Test API in the builder does not send room_id, because no room is open.

  • From the agent's browser (agent widget): the request is sent directly from the browser of the agent who opens the form, not from Qiscus servers. Your endpoint must be reachable from the agent's network and must allow cross-origin requests (CORS), including the headers configured on the field.

  • Headers are visible: because the request runs in the browser, the configured header values (for example an API key) can be seen with the browser's developer tools by anyone who opens the form. They are also included in the webhook body, in the field's payload. Use a key that can only read this list.

  • When it is called: when the field is shown to the agent. Searching filters the choices already loaded and does not call your endpoint again. For Dropdown and Multi-select, if the call fails, the agent can close and reopen the list to retry.

  • Nested levels: each level of a Nested API Dropdown calls its own endpoint: the first level when the field is shown, and each next level when the choice in the level before it changes. The admin passes that choice as a query parameter using a placeholder such as {[Category]}, which is replaced with the selected value. Until the previous level has a choice, the agent sees Please complete the previous field first.

  • From Omniform's servers (customer page):

    • Your endpoint must be reachable from the internet. An endpoint that only answers on your office network or VPN works in the agent widget but not on the customer page.

    • Private, loopback, and link-local addresses, and redirects to them, are refused.

    • Each request must answer within 10 seconds and with at most 1 MB; otherwise the customer sees that the choices could not be loaded.

    • Only label and value of each choice reach the customer's browser. Other keys in your response are dropped.

    • CORS does not apply, because the request does not come from a browser.

Changing the source of a saved field

An admin can switch a saved Dropdown or Multi-select field between Manual and From API at any time. The builder does not ask for confirmation. After the form is saved, new submissions change as follows:

  • Dropdown: value changes from the choice label to the value from your API, for example from "Shipping" to "CAT-14". If your system stores or matches the label, read selected_option.label instead.

  • Multi-select: value keeps the same shape, but each value inside it changes from the label to the value from your API, for example from { "label": "Price inquiry", "value": "Price inquiry" } to { "label": "Price inquiry", "value": "TAG-104" }.

  • Payload keys: source changes to "api", api is added, and dropdowns is no longer sent.

  • Switching back to Manual reverses all of the above.

Submissions that were already sent are not changed. Agents who have the form open while the change is saved see This form was just updated. Check your answers, then submit again. when they submit; their answers to unchanged fields are kept.

Webhook

Each submission is sent to the Webhook URL configured in the form's Submit Action step.

Request

POST {{WEBHOOK_URL}}
{
"Content-Type": "application/json",
"User-Agent": "custom-form-addon",
"{{Custom-Header-Key}}": "{{Custom-Header-Value}}"
}

The form can define up to 5 custom headers. Use them to verify that a request comes from Omniform. Submissions from customer links use the same URL and headers. The Webhook URL must use HTTPS for new forms and for URLs changed in the builder, and must point to a public address: private, loopback, and link-local addresses, and redirects to them, are refused.

Body

attachments, conversation_file, additional_info, customer_properties, and room_info are included only when the matching option is turned on in the form. source is always included; form_link only for submissions from a customer link. The sample is shortened: each field's payload also carries that field's settings, such as its choice list or endpoint configuration.

{…}

Key

Description

name, description

The form's name and short description

author

Who submitted the form: id, name, email, role, and division. For an agent, as provided by Omnichannel. For a customer link, see Submissions from a customer link

fields

One object per field, in form order. See Field values by type

attachments

Files attached to the submission, by the agent or by the customer. Up to 5 files, 50 MB each

conversation_file

Link to the conversation history of the current session. Available only when the room is already resolved; otherwise null

additional_info

The room's additional information. null when the form appends it to a Multi-line Text field instead

customer_properties

The selected customer properties

room_info

The selected room details. Only the items ticked in the form are included

source

"agent" for submissions from the agent widget, "customer_link" for submissions from a customer link

form_link

Customer link submissions only. See Submissions from a customer link

A submission from a customer link goes to the same webhook URL, with the same headers and the same fields format. Two things differ.

author is the customer, taken from the room:

"author": {
"id": "6281200000000",
"name": "Budi Santoso",
"email": "",
"role": "customer",
"division": null
}
  • id is the customer's ID in the channel (for example a phone number), sent as a string. For agents, id is a number.

  • name is the room name, usually the customer's name. email is empty when the room has no customer email.

  • If your integration uses author.email to find or assign an agent, check source first.

form_link describes the link that was used:

"source": "customer_link",
"form_link": {
"token": "9f2c4a7e0b1d4c6f8a3e5b7d9c1f2e4a",
"created_via": "api",
"created_by": { "type": "api_token", "label": "Chatbot WhatsApp" },
"metadata": { "bot_session": "abc-123" },
"created_at": "2026-10-19T09:00:00Z",
"expires_at": "2026-10-20T09:00:00Z"
}

Key

Description

token

The link token, the last part of the link URL

created_via

Always "api"

created_by

The API token that created the link: type is "api_token", label is the token name at the time the link was created

metadata

The metadata your system sent when it created the link, or null

created_at, expires_at

When the link was created and when it expires, in UTC

Other things to know:

  • A customer usually fills the form while the chat is still open, so conversation_file is normally null.

  • An agent and a customer who submit the same form in the same room overwrite each other's stored answers in Omniform. You still receive a webhook for each submission.

  • Treat customer attachments as untrusted files. Omniform checks the file type and size, but does not scan for viruses. Office files can contain macros, and CSV files can contain formulas that run when opened in a spreadsheet.

Field values by type

Each field's answer is in fields[].payload.value:

Field type

type

payload.value

Single-line Text

single_line

Text

Multi-line Text

multi_line

Text

Phone Number

phone_number

Text with digits only, country code first, no + ("628123456789")

Checkbox

checkbox

A JSON-encoded string of the selected labels ("[\"Email\",\"WhatsApp\"]"). Parse it as JSON to get the list

Dropdown

dropdown

Text: the selected choice. With a From API source, the value returned by your endpoint. When an optional Dropdown is left empty, value is "" or missing; treat an empty or missing value as not selected

Nested API Dropdown

nested_api_dropdown

Array with one object per level: the level's identifier and label, and the selected value

Date and Time

date

ISO 8601 timestamp in UTC ("2026-10-13T03:00:00.000Z")

Number and Decimal

number / decimal

Number. The type is decimal when the field allows decimals

Multi-select

multi_select

Array of the selected choices as {label, value} objects, in list order. [] when an optional field is left empty

  • For Checkbox and Dropdown, payload.choices / payload.dropdowns contain only the selected choices.

  • identifier is a stable key for each field. Map fields by identifier rather than label, because a label can be renamed.

Key

Fields

Description

source

dropdown, multi_select

"manual" or "api". Dropdown fields created before Choices source existed have no source; treat them as "manual"

api

dropdown, multi_select with source: "api"

Endpoint configuration: url, method ("GET"), headers, query_params

min_selections, max_selections

multi_select

The Choice Limit. Present only when set

selected_option

dropdown

{ "label", "value" } of the selected choice, for both sources. For Manual, value equals label. Not present when an optional dropdown is left empty

dropdowns

dropdown, multi_select with source: "manual"

The selected choices, in the same format as before

Multi-select sends the same {label, value} shape for both sources. For a Manual source, each value equals its label.

{
"type": "multi_select",
"label": "Conversation Tags",
"is_required": true,
"identifier": "conversation_tags-k3x9",
"payload": {
"source": "api",
"api": {
"url": "https://api.example.com/omniform/tags",
"method": "GET",
"headers": { "X-Api-Key": "{{YOUR_READ_ONLY_KEY}}" },
"query_params": {}
},
"min_selections": 1,
"max_selections": 4,
"value": [
{ "label": "Price inquiry", "value": "TAG-104" },
{ "label": "Account locked", "value": "TAG-221" }
]
}
}
{
"type": "multi_select",
"label": "Contact Reason",
"is_required": true,
"identifier": "contact_reason-p5q6",
"payload": {
"source": "manual",
"max_selections": 4,
"value": [
{ "label": "Price", "value": "Price" },
{ "label": "Account", "value": "Account" }
],
"dropdowns": [
{ "label": "Price", "order": 1, "selected": true },
{ "label": "Account", "order": 2, "selected": true }
]
}
}

Dropdown also sends payload.selected_option with both the label and the value of the selected choice. payload.value stays a plain string, so existing integrations keep working.

{
"type": "dropdown",
"label": "Category",
"is_required": false,
"identifier": "category-r7s8",
"payload": {
"source": "api",
"api": {
"url": "https://api.example.com/omniform/categories",
"method": "GET",
"headers": {},
"query_params": {}
},
"value": "CAT-14",
"selected_option": { "label": "Shipping", "value": "CAT-14" }
}
}

Responding to Omniform

Respond with any HTTP 2xx status (200–299) once you have accepted the submission. Any other status is treated as a failure. Until this release, only 200 counted as success.

To show your own error message to the agent, return one of these structures. If more than one is present, Omniform uses the first match in this order: error.data.message, error.message, message.

Preferred:

{
"message": "Your error message here",
"details": [
{ "field": "field_name", "message": "Field-specific error message" }
]
}

Alternative 1:

{
"error": {
"message": "Your error message here",
"details": [
{ "field": "field_name", "message": "Field-specific error message" }
]
}
}

Alternative 2:

{
"error": {
"data": {
"message": "Your error message here",
"details": [
{ "field": "field_name", "message": "Field-specific error message" }
]
}
}
}
  • message is shown to the agent.

  • details is optional and is not shown to the agent. Use it for debugging.

These messages are shown only in the agent widget. On the customer page, a failed submission always shows a generic message, so your error text never reaches the customer.

If none of these structures is returned, the agent sees:

unexpected error when sending data to webhook url <your-url> status code: <status-code>

When a submission fails

  • Omniform sends each submission once and does not retry it automatically.

  • Omniform waits 15 seconds for your response. A slower response counts as a failure.

  • The agent sees the error message, and their answers stay in the form, so they can correct them and submit again. Each attempt is a new request to your webhook.

  • The agent waits for your response before seeing the result, so respond quickly.

  • On a customer link, a failure keeps the link usable, and the customer can submit again. Use form_link.token to avoid creating a duplicate record for the same link.

If your service has already processed a submission but responds with a non-2xx status, the agent or customer will likely submit it again. Return a 2xx status as soon as you accept the data, and make sure a repeated submission does not create a duplicate record.

Troubleshooting

The agent sees "unexpected error when sending data to webhook url …"

Your webhook responded with a non-2xx status, did not respond within 15 seconds, or returned a body Omniform does not recognize. Check the status code in the message, and see Responding to Omniform.

Choices load for agents but not on the customer page

On the customer page, choices are requested from Omniform's servers, not from a browser. Check that your endpoint is reachable from the internet (not only from your office network or VPN), does not redirect to a private address, and answers within 10 seconds with at most 1 MB. See How the request is sent.

author.id is a string instead of a number

That is a submission from a customer link: author.id is the customer's ID in the channel. Check source to tell agent and customer submissions apart.

Choices from my API don't load in the agent widget

Check that the endpoint allows cross-origin requests (CORS), including the headers configured on the field, and that it returns a plain JSON array of {label, value} objects. Use Test API in the builder to see the response.

The Checkbox value is a string, not an array

That is the Checkbox format. Parse payload.value as JSON to get the list of selected labels.

Dates are a few hours off

Date and Time values are sent in UTC. Convert them to your timezone.