Webhook
Webhooks send your form response data to any URL whenever someone submits your form. Use them to connect FormGrid to your own backend, Zapier, Make, or any other service that accepts webhooks.
Setting up a webhook
- Open your form and go to the Connect tab
- Click Connect next to Webhook
- In the Integration Settings dialog, enter a Name for the integration
- Enter your Webhook URL — the endpoint that will receive the data
- (Optional) Click Add signing secret to generate a signing secret for security
- Click Create
Signing secret
The signing secret lets your server verify that incoming webhook requests actually came from FormGrid. When set, FormGrid includes a signature header with each request that your server can validate. This prevents anyone from spoofing submissions to your endpoint.
Event log
Every webhook call is logged. Check the event log to see:
- Whether requests were delivered successfully
- Error messages if delivery failed
- Timestamps for each event
This is your go-to tool for debugging webhook issues.
Use cases
- Custom backends — send form data directly to your app’s API
- Automation platforms — connect to Zapier, Make, n8n, or similar tools
- Slack/Discord notifications — trigger a message when someone submits
- CRM updates — push new leads or contacts into your CRM
Tips
- Test your webhook URL with a test submission before going live
- Check the event log if responses aren’t arriving at your endpoint
- Use the signing secret in production for security
Developer reference
Request format
FormGrid sends a POST request to your webhook URL with a JSON body. Every request includes these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | FormGrid-Webhook |
X-FormGrid-Signature | sha256=<hex> (only if a signing secret is configured) |
Requests time out after 10 seconds. Your endpoint should respond promptly — do any heavy processing asynchronously after returning a 2xx response.
Verifying signatures
If you’ve configured a signing secret, every request includes an X-FormGrid-Signature header. The signature is an HMAC-SHA256 hex digest of the raw request body, prefixed with sha256=.
To verify:
- Read the raw request body (before any JSON parsing)
- Compute
HMAC-SHA256of the body using your signing secret as the key - Compare the resulting hex digest against the value in the header (after stripping the
sha256=prefix)
Example in Node.js:
import crypto from "crypto"
function verifySignature(body, secret, signatureHeader) {
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex")
return signatureHeader === `sha256=${expected}`
}
Event payload
Every webhook delivery sends a single form_response event:
{
"event_id": "Kj7mNpQr2sVwXy4z",
"event_type": "form_response",
"created_at": "2025-01-15T09:30:00.000Z",
"data": {
"form": {
"id": "bR3kWn8fTx1qLm5v",
"created_at": "2024-12-01T12:00:00.000Z",
"title": "Customer Feedback",
"version": 3
},
"form_response": {
"id": "hY6pCd9jNt2wAe4s",
"created_at": "2025-01-15T09:29:58.000Z",
"fields": []
}
}
}
| Property | Type | Description |
|---|---|---|
event_id | string | Unique ID for this webhook delivery |
event_type | string | Always form_response |
created_at | string | ISO 8601 timestamp of the event |
data.form.id | string | Form ID |
data.form.created_at | string | When the form was created |
data.form.title | string | Form title |
data.form.version | number | Current form version |
data.form_response.id | string | Response ID |
data.form_response.created_at | string | When the response was submitted |
data.form_response.fields | array | Array of field objects (see below) |
Field types
Each entry in fields has a common shape and a type-specific value. Fields that the respondent left blank have a value of null.
Every entry also carries a display_label alongside its label. The two are identical unless the question text contains references or inline calculations (see References in labels). The examples below leave display_label out for brevity.
Short text
{
"id": "a3Bk",
"type": "text_short",
"label": "Your name",
"config": { "required": true, "unique": false, "max_chars": 100 },
"value": "Jane Doe"
}
Long text
{
"id": "Xm9p",
"type": "text_long",
"label": "Comments",
"config": { "required": false, "max_chars": 2000 },
"value": "Great product, very intuitive."
}
Number
The value is a plain number without formatting. The display_value is the formatted string with prefix, suffix, decimal padding, and separator applied — ready for display without any additional processing.
{
"id": "Tk9w",
"type": "number",
"label": "Amount",
"config": {
"required": true,
"min": 0,
"max": null,
"prefix": "$",
"suffix": null,
"decimal_places": 2,
"decimal_separator": ".",
"pad_decimals": true
},
"value": 25.5,
"display_value": "$25.50"
}
Multiple choice
Options are denormalized into the payload so you don’t need to look them up. When max_selections is 1, it’s a single-select question — the value array will contain at most one entry. If “other” is enabled, options carries a matching other entry, and if the respondent chose it, their value entry will have id: "other" with their freeform text.
{
"id": "Qf7j",
"type": "multiple_choice",
"label": "Favorite color",
"config": {
"required": true,
"max_selections": 1,
"allow_other": true,
"randomize": false
},
"options": [
{ "id": "Rv3m", "label": "Red" },
{ "id": "Jb8w", "label": "Blue" },
{ "id": "other", "label": "Other" }
],
"value": [{ "id": "Rv3m", "text": "Red" }]
}
An “other” response looks like:
"value": [{ "id": "other", "text": "Purple" }]
Country
value has the same shape as multiple choice, though country fields carry no options array — the option set is the ISO country list. The id is the ISO 3166-1 alpha-2 country code, and text is the country name in the form’s language. When multi is false, the value array contains at most one entry.
{
"id": "Yk2p",
"type": "country",
"label": "Where are you based?",
"config": { "required": true, "multi": false },
"value": [{ "id": "DE", "text": "Germany" }]
}
In multi mode:
"value": [
{ "id": "DE", "text": "Germany" },
{ "id": "FR", "text": "France" }
]
The text is localized to the form’s language (so the same submission would render as "Deutschland" if the form is set to German). Use id for stable processing and text for display.
Phone number
The value is always an E.164 string with the country code prefix and no spaces or punctuation. A German mobile typed as “176 12345678” arrives as +4917612345678. The display_value is the same number in the pretty international format, ready to show without any additional processing. If default_country was set in the field’s settings, it’s echoed back in config so consumers know which country the picker was pre-selected to (the respondent may have changed it). default_country uses the same shape as default_value (see Field defaults below).
{
"id": "Mc7p",
"type": "phone",
"label": "Phone number",
"config": { "required": true, "unique": false, "default_country": { "value": "DE" } },
"value": "+4917612345678",
"display_value": "+49 176 12345678"
}
Rating
The value is a number from 1 to max_score.
{
"id": "Ys8c",
"type": "rating",
"label": "How would you rate us?",
"config": { "required": true, "max_score": 5 },
"value": 4
}
Linear scale
The value is a number between start and end (inclusive).
{
"id": "Dg5r",
"type": "linear_scale",
"label": "How likely are you to recommend us?",
"config": {
"required": true,
"start": 0,
"end": 10,
"start_label": "Not likely",
"end_label": "Very likely"
},
"value": 8
}
Ranking
Similar to multiple choice, but the order of items in value represents the respondent’s ranking from first to last.
{
"id": "Ht4v",
"type": "ranking",
"label": "Rank these features",
"config": { "required": true, "randomize": true },
"options": [
{ "id": "Wn5a", "label": "Speed" },
{ "id": "Ep2k", "label": "Design" },
{ "id": "Fc2x", "label": "Price" }
],
"value": [
{ "id": "Fc2x", "text": "Price" },
{ "id": "Wn5a", "text": "Speed" },
{ "id": "Ep2k", "text": "Design" }
]
}
{
"id": "Lw2n",
"type": "email",
"label": "Email address",
"config": { "required": true, "unique": false },
"value": "jane@example.com"
}
File upload
Each file includes a signed URL that is valid for a limited time.
{
"id": "Nk1e",
"type": "file_upload",
"label": "Upload your resume",
"config": {
"required": false,
"allowed_types": ["pdf", "docx"],
"max_files": 3,
"max_size_bytes": 10485760
},
"value": [
{
"id": "mT5xKw3pRn7vQj9s",
"name": "resume.pdf",
"mime_type": "application/pdf",
"size": 204800,
"url": "https://files.formgrid.com/..."
}
]
}
Signature
The signature is stored as an image file. The value includes a signed URL that is valid for a limited time.
{
"id": "Vt3w",
"type": "signature",
"label": "Your signature",
"config": { "required": true },
"value": {
"id": "qR7nLx2pWm4vKj8s",
"name": "signature.svg",
"mime_type": "image/svg+xml",
"size": 15320,
"url": "https://files.formgrid.com/..."
}
}
Hidden field
Hidden fields are never shown to the respondent. They’re typically populated via URL parameters.
{
"id": "Pz6u",
"type": "hidden",
"label": "utm_source",
"config": { "required": false, "unique": false },
"value": "newsletter"
}
Variable
Variables appear alongside regular fields with their computed value. The config.formula property contains the original expression.
{
"id": "Bm4t",
"type": "variable",
"label": "total",
"config": { "formula": "@Nq4v * @Hs7d" },
"value": 150
}
Field defaults
Any field that supports a default value carries a config.default_value property describing the configured default. Phone’s default_country uses the same shape. Three possible values:
nullif no default is configured (the most common case).{ "value": <raw> }for a literal default. The shape of<raw>depends on the field: a string for text/email/country code, a number for rating/linear scale, etc.{ "ref_id": "<id>" }for a reference to another field or variable. The respondent sees that ref’s value at the time the form is rendered.
Defaults are best-effort. If the literal value (or the ref’s resolved value) doesn’t pass the field’s validation or doesn’t match the expected type, FormGrid ignores it and the field renders empty. This means the default_value echoed in the payload represents what was configured by the form author, not necessarily what the respondent saw.
A short text field with a literal default:
"config": { "required": true, "max_chars": null, "default_value": { "value": "Anonymous" } }
The same field referencing a hidden userName field by node ID:
"config": { "required": true, "max_chars": null, "default_value": { "ref_id": "uN3m" } }
No duplicates
Short text, Email, Phone number, and Hidden field each carry a config.unique boolean. When true, the field has the No duplicates setting enabled, and FormGrid rejects new submissions whose value would duplicate an earlier accepted submission for the same field. See Preventing duplicate submissions for matching rules and edge cases.
References in labels
Question text can contain @ references and inline calculations, so the question a respondent saw may include values they entered earlier. Every field entry therefore carries two labels:
| Property | Description |
|---|---|
label | The question as authored, with references left unresolved. Never depends on the answers. |
display_label | The question exactly as the respondent saw it, with everything resolved. |
In label, a reference is rendered as @{...} around the display name of the referenced field or variable, and an inline calculation as {...} around its expression:
{
"id": "Jt6q",
"type": "text_long",
"label": "Why did you choose @{Favorite color}?",
"display_label": "Why did you choose Blue?",
"config": { "required": false, "max_chars": 2000 },
"value": "It matches our brand."
}
{
"id": "Rd2k",
"type": "text_long",
"label": "Your total is {@Price * 2}. Anything we should know?",
"display_label": "Your total is $50.00. Anything we should know?",
"config": { "required": false, "max_chars": 2000 },
"value": "Please invoice our finance team."
}
Which one to use:
labelfor anything that has to read the same across submissions, such as a column header in a spreadsheet or a title in a dashboard.display_labelwhenever you show a single response to a person, such as a notification, a ticket, or a support tool.
Neither is an identifier. Key your own storage on the field id, which stays the same when the form author edits the question text.
display_label is always present. When a question contains no references, it is identical to label, so you can render it unconditionally without a fallback.
Repeated fields
Fields inside a repeat block appear once per iteration with a suffixed ID and a label suffixed with the iteration number. Each entry includes a repeat object with the base_id (the original field ID) and a path array indicating the iteration number.
{
"id": "Xk3p~1",
"type": "text_short",
"label": "Guest name (1)",
"config": { "required": true, "max_chars": null },
"repeat": { "base_id": "Xk3p", "path": [1] },
"value": "Alice"
},
{
"id": "Xk3p~2",
"type": "text_short",
"label": "Guest name (2)",
"config": { "required": true, "max_chars": null },
"repeat": { "base_id": "Xk3p", "path": [2] },
"value": "Bob"
}
Use repeat.base_id to group iterations of the same field, and repeat.path to determine the iteration order.
For nested repeats (a repeat inside another repeat), the path array has one entry per nesting level, and the label suffix joins the levels with a dot. For example, a field in the 2nd iteration of an outer repeat and the 1st iteration of an inner repeat:
{
"id": "Lw2n~2~1",
"type": "text_short",
"label": "Skill (2.1)",
"config": { "required": false, "max_chars": null },
"repeat": { "base_id": "Lw2n", "path": [2, 1] },
"value": "JavaScript"
}
Conditional fields
Fields inside a condition are always present in the payload regardless of whether the condition was true or false. When a field was hidden (condition was false), its value is null — the same as if the respondent left it blank. This keeps the payload schema consistent across submissions.
Error handling
Your endpoint should return a 2xx status code to indicate success. Any other status code is treated as a failure, and the delivery will be logged as an error in the event log. Network errors (DNS failures, timeouts, connection refused) are also logged.
Failed deliveries are automatically retried up to 5 times with increasing delays: 5 minutes, 30 minutes, 1 hour, 6 hours, and 1 day.