דלג לתוכן מרכזי

Developer docs · Reference

Formy webhooks: payload, X-Formy-Signature and retries

A Formy webhook sends an HTTPS POST with a JSON body to your URL each time a response is completed, and again each time a completed response is edited. Each request names the event, carries a delivery ID that stays the same on every retry, and, when you set a secret, an X-Formy-Signature you can verify. A server that is down is retried 8 times over about 24 hours.

Webhooks are on every plan, Free included. Set one up on a form's אינטגרציות tab, or by API. If you are choosing between a webhook and a no-code tool, webhooks for forms compares the options, and the step-by-step guide sets one up without code.

Events
form.submission.created, form.submission.updated
Signature
X-Formy-Signature: t=<unix>,v1=<HMAC-SHA256>
Timeout
10 seconds per attempt
Retries
8 attempts over 24 hours
Address
HTTPS, public IP addresses only, no redirects
Plans
Every plan, Free included
By , CTO of FormyPublished Updated

Written with AI assistance and checked against the product on . Approved for publication by Tomer Gal, CTO of Formy. How we write and approve content (Hebrew)

Set up a webhook

  1. Open the form and its אינטגרציות tab, and click הוספת אינטגרציה.
  2. Choose התראת Webhook.
  3. Paste your endpoint into כתובת ה-Webhook. It must be an https:// address.
  4. Open הגדרות מתקדמות to set a סוד ה-Webhook (at least 12 characters) and up to 10 כותרות מותאמות.
  5. Save, and submit a test response. Every attempt is listed under אירועים קודמים on the webhook's card.

To send only some responses, add a condition under מתי האינטגרציה תרוץ?. A response that does not meet it is not sent, and the history records it as skipped.

Events

Webhook events
X-Formy-EventSent when
form.submission.createdA respondent completes the form.
form.submission.updatedA completed response changes: the respondent edits it through its edit link, a resubmission updates it, or the form owner edits it in Formy.

A partial save (the respondent has not finished) sends nothing. Neither does a deletion: Formy has no delete event.

Request headers

Headers on every webhook request
HeaderSentValue
X-Formy-EventAlwaysform.submission.created or form.submission.updated
X-Formy-DeliveryAlwaysA UUID for this delivery. The same on every retry of it.
X-Formy-SignatureWith a secrett=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">
X-Webhook-SignatureWith a secretsha256=<hex HMAC-SHA256 of the body>, the original scheme
User-AgentAlwaysFormy-Webhooks/1.0
Content-TypeAlwaysapplication/json
Your custom headersIf setUp to 10, sent with every request

The payload

This is a real delivery for a synthetic response, as the code sent it: a form with a text field, an email, a select, a number and a hidden field, opened from a link carrying utm_source, utm_campaign and gclid.

{
  "form_id": 1,
  "form_title": "Service agreement",
  "form_slug": "service-agreement",
  "submission": {
    "Full name": "Dana Cohen",
    "Email": "dana@example.com",
    "Package": "Package 2",
    "Monthly fee": 13000,
    "Client ID": "C-1042"
  },
  "data": {
    "3f6c2a1e-8b4d-4c2f-9a71-1d2e3f4a5b6c": { "value": "Dana Cohen", "name": "Full name", "type": "text" },
    "7b1e9c40-2d3a-4f5b-8c6d-0e1f2a3b4c5d": { "value": "dana@example.com", "name": "Email", "type": "email" },
    "8a1d4e2b-5c3f-4a6e-b7d8-9e0f1a2b3c4d": { "value": "Package 2", "name": "Package", "type": "select" },
    "c4b3a2d1-6e5f-4d7c-8b9a-0f1e2d3c4b5a": { "value": 13000, "name": "Monthly fee", "type": "number" },
    "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9": { "value": "C-1042", "name": "Client ID", "type": "text" }
  },
  "message": "השדה `submission` מיושן ויוסר בעתיד. אין להשתמש בו יותר.",
  "submission_id": 1,
  "attribution": {
    "utm_source": "newsletter",
    "utm_campaign": "autumn",
    "gclid": "abc123"
  }
}
Payload keys
KeyAlways?What it holds
form_id, form_title, form_slugYesWhich form the response belongs to.
submission_idYesThe response's number. Use it to create or update your own record.
dataYesThe answers, keyed by field ID. Each entry has value, the field's name and its type. A repeating group's value is a list of items, and the entry adds fields with the names and types of its child fields.
submissionYesThe same answers keyed by field name. Deprecated: names change and can repeat. Read data.
messageYesA notice, in Hebrew, that submission is deprecated.
attributionWhen presentThe campaign parameters of the link the respondent arrived from. See below.
edit_linkWhen enabledThe respondent's link for editing this response, on forms that allow editing responses.
pdf_url, pdf_base64When enabledThe response's PDF. See below.

Hidden fields are included. Layout blocks (headings, text, images, page breaks) are not, because they hold no answer. A field the respondent left empty is left out of data.

Campaign attribution: the attribution field

When the respondent arrived from a link with campaign parameters, attribution carries them, separate from the answers, so you can route a lead by campaign without adding a field for it. Formy records these 16 parameters and no others:

utm_source
utm_medium
utm_campaign
utm_id
utm_term
utm_content
utm_source_platform
utm_creative_format
utm_marketing_tactic
gclid
gbraid
wbraid
dclid
fbclid
ttclid
msclkid
  • It is first-touch: the parameters from the visit that started the response. Later visits do not overwrite them.
  • A value longer than 2,048 characters, or an empty one, is dropped. An unknown parameter is ignored.
  • In an embedded form, the parameters are read from the page that hosts the form, through the embed SDK.
  • Attribution is recorded whether or not the respondent agreed to analytics, because it is part of the link they clicked, not something measured about them. It is not recorded on a form using the "שאלון מוגן" privacy preset.
  • The key is absent when there is nothing to report.

Formy also shows these values as columns in the responses table and its exports. Measuring the visit itself, with your own GA4, Meta Pixel or GTM, is a separate setting: see conversion tracking.

Files and PDFs in the payload

An upload field's value is a list of signed links to the files. A link stops working after 24 hours by default; a workspace admin can choose 3, 7, 14 or 30 days under תוקף קישורים לקבצים in the workspace's אבטחה ונתונים settings. Each retry signs its links afresh. Download the files when the delivery arrives rather than storing the links.

To include the response's PDF, set the form's PDF setting צירוף לאוטומציות to להוסיף קישור הורדה בלבד, which adds pdf_url (a signed link valid for 7 days), or להוסיף קישור ואת הקובץ עצמו (Base64), which also adds the file itself as pdf_base64. Formy waits up to about 30 seconds for the PDF to be ready; if it is not, the delivery goes out without it.

Verify the signature

With a secret set, every request is signed. Verify X-Formy-Signature: it proves the request came from Formy, that the body was not changed, and when it was sent, so a captured request cannot be replayed later.

  1. Split the header on , into t (a Unix timestamp in seconds) and v1 (the signature).
  2. Reject the request if t is more than 5 minutes away from your clock.
  3. Compute HMAC-SHA256 with your secret over t, a period (.) and the raw request body, and hex-encode it.
  4. Compare the result with v1 in constant time. Reject the request if they differ.
Node.js (Express)
const express = require('express')
const crypto = require('crypto')

const app = express()
const SECRET = process.env.FORMY_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 300

// express.raw keeps the exact bytes Formy signed.
app.post('/hooks/formy', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Formy-Signature') || ''
  const parts = Object.fromEntries(header.split(',').map((part) => part.split('=', 2)))
  const timestamp = Number(parts.t)
  if (!SECRET || !parts.v1 || !Number.isInteger(timestamp)) return res.status(401).send('Missing signature')
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return res.status(401).send('Stale signature')

  const expected = crypto.createHmac('sha256', SECRET).update(`${timestamp}.`).update(req.body).digest('hex')
  const received = Buffer.from(parts.v1)
  const wanted = Buffer.from(expected)
  if (received.length !== wanted.length || !crypto.timingSafeEqual(received, wanted)) {
    return res.status(401).send('Invalid signature')
  }

  const delivery = req.get('X-Formy-Delivery') // skip it if you have processed it before
  const event = req.get('X-Formy-Event')
  const payload = JSON.parse(req.body.toString('utf8'))
  console.log(event, delivery, payload.submission_id)
  res.sendStatus(200)
})

app.listen(3000)
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ['FORMY_WEBHOOK_SECRET'].encode()

@app.post('/hooks/formy')
def formy_webhook():
    parts = dict(p.split('=', 1) for p in request.headers.get('X-Formy-Signature', '').split(',') if '=' in p)
    try:
        timestamp, received = int(parts['t']), parts['v1']
    except (KeyError, ValueError):
        abort(401)
    if abs(time.time() - timestamp) > 300:
        abort(401)
    raw = request.get_data()  # the exact bytes Formy signed
    expected = hmac.new(SECRET, f'{timestamp}.'.encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(received, expected):
        abort(401)
    payload = request.get_json()
    # skip request.headers['X-Formy-Delivery'] if you have processed it before
    return '', 200
PHP (Laravel)
<?php
// Laravel route; any PHP framework works the same way.
Route::post('/hooks/formy', function (Illuminate\Http\Request $request) {
    $secret = env('FORMY_WEBHOOK_SECRET');
    parse_str(str_replace(',', '&', (string) $request->header('X-Formy-Signature')), $parts);
    $timestamp = (int) ($parts['t'] ?? 0);
    $received = (string) ($parts['v1'] ?? '');
    if (! $secret || $received === '' || abs(time() - $timestamp) > 300) {
        abort(401);
    }
    // getContent() is the raw body, exactly as Formy signed it.
    $expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $secret);
    if (! hash_equals($expected, $received)) {
        abort(401);
    }
    $payload = $request->json()->all();
    // skip $request->header('X-Formy-Delivery') if you have processed it before
    return response()->noContent();
});

Test vector

Check your code against these values before you deploy it. With the secret below, this timestamp and this exact body, your code must produce this v1:

secret:    formy-docs-test-secret
t:         1760000000
body:      {"form_id":1,"form_slug":"service-agreement","submission_id":42}
v1:        a19954cefa0c3d8905f15db9c8ad77e85b8a338fad114a86761f8ce802c3ef68
legacy:    sha256=12ccf2f4ef7f8d199692cd842bac9c42d1300d33135e0d08cbb314e35cde7e56
printf '%s' '1760000000.{"form_id":1,"form_slug":"service-agreement","submission_id":42}' \
  | openssl dgst -sha256 -hmac 'formy-docs-test-secret' -hex
The same check from a terminal.

X-Webhook-Signature is the original scheme, kept for receivers built on it: an HMAC-SHA256 of the body alone, prefixed with sha256=. It has no timestamp, so it cannot stop a replay. Use X-Formy-Signature in new code.

Retries, failures and timeouts

Answer with a 2xx within 10 seconds. Formy does not follow redirects and counts a 3xx as delivered, which is how Google Apps Script web apps answer.

What Formy does with each answer
Your serverWhat Formy does
Answers 2xx or 3xxRecords the delivery as successful.
Cannot be reached, its name does not resolve, or takes longer than 10 secondsTries again later.
Answers 408, 425, 429 or any 5xxTries again later.
Answers any other 4xxStops at once, records the failure and emails the form's owner.

Attempts are made at the first try and then 30 seconds, 2 minutes, 10 minutes, 1 hour, 4 hours, 12 hours, 24 hours after it, 8 in all. Each wait can run up to a tenth longer, so the retries piled up during an outage do not all arrive in the same second.

  • Every attempt is built when it is sent: a change to the URL, secret or headers applies to the retries still waiting, and a response deleted in the meantime is not sent.
  • Pausing or deleting the webhook cancels the retries still waiting.
  • When the last attempt fails, the form's owner gets one email. To keep a broken endpoint from flooding the inbox, Formy sends at most one such email per webhook a day for each cause, such as a 404 or an unreachable host.
  • The response itself is always saved in Formy. Every attempt's outcome is under אירועים קודמים, and GET /open/forms/{form}/integrations/{integration}/events returns the same history.
The past-events window of an integration in Formy, in Hebrew, with times in the Asia/Jerusalem time zone: two runs marked successful and one marked not sent because the integration's condition was not met, each with a link to technical details.
The אירועים קודמים window lists every run of an integration: what succeeded, what was not sent and what failed, and why. This one belongs to an email notification; a webhook's looks the same. Screenshot of the product, with made-up data.
[
  {
    "id": 1,
    "date": "2026-10-17 05:37",
    "created_at": "2026-10-17T05:37:41.000000Z",
    "status": "success",
    "data": { "delivery_id": "4fe482e4-56d7-4ef8-ab59-898acb70a211", "attempts": 1 }
  }
]
The delivery history from GET https://formy.co.il/api/open/forms/{form}/integrations/{integration}/events.

Duplicates and order

A delivery can reach you more than once, for example when your server processes it but answers after the 10-second limit and Formy retries. Store each X-Formy-Delivery you have processed and skip one you have seen.

Retries also mean deliveries can arrive out of order: an edit can arrive before a retried creation of the same response. Key your own record on submission_id, and create or update it, rather than always adding a new one.

URL rules and custom headers

  • The URL must start with https://. A plain http:// address is refused when you save.
  • It must resolve only to public IP addresses. An address on a private network, or on your own machine, is refused. To test locally, expose your server through a tunnel that gives you a public HTTPS address.
  • The secret is optional and at least 12 characters long. Use a long random one.
  • You can add up to 10 custom headers, each name and value up to 255 characters. To pass a token to your server, use a header of your own, such as X-Api-Key.

Formy refuses custom headers it sets itself or that could mislead your server:

Authorization
X-Webhook-Signature
X-Formy-Signature
X-Formy-Event
X-Formy-Delivery
Content-Type
Content-Length
Host
Cookie
X-CSRF-Token
X-Forwarded-For
X-Forwarded-Proto
X-Real-IP
X-Requested-With
Accept-Encoding
Transfer-Encoding

Make, n8n and the other automation cards

The Make card, and the cards for n8n, Activepieces, Pabbly and Albato, post the same payload without submission and message, but they are not webhooks in the sense of this page: they carry no signature headers, make one attempt with the same 10-second timeout, and do not retry. If you need signing and retries, point a התראת Webhook at the platform's incoming-webhook URL instead. See Automation endpoints.

Questions developers ask

Is a Formy webhook sent for partial responses?

No. A webhook is sent when a response is completed, and again when a completed response is edited. Partial saves of an unfinished response, available on Basic and above, send nothing.

Does Formy send a webhook when a response is deleted?

No. There is no delete event. A response deleted while a retry was waiting is simply not sent.

Can I resend a failed delivery?

Not by hand. Formy retries on its own for about 24 hours. After that the response is still in Formy, so you can export it from the responses table, or edit and save it again, which sends the updated event.

Which IP addresses do webhooks come from?

Formy does not publish a fixed list of sending addresses. Authenticate requests by their signature, or by a custom header with a value only you and Formy know, rather than by IP address.

Where do I find the field IDs used as keys in data?

On the form's integrations tab, under "אוטומציה ו-API", or by API from GET /external/forms/{form}/fields. Each entry in the payload also carries the field's name and type.

Every page of the docs is listed in Formy developer docs .

Try it on a free account

Every plan includes the API, webhooks, the embed SDK and the MCP server, Free included.

Create a free account