Sharing a small workflow we use for form leads. Disclosure: we build Formgong, the form backend that sends these webhooks. But the pattern works for any service that signs the raw body with HMAC-SHA256 and sends the result in a header.
What the webhook looks like
POST <your n8n production URL>
Content-Type: application/json
User-Agent: Formgong-Webhook
X-Formgong-Event: submission.created
X-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with the form's signing secret>
{
"event": "submission.created",
"form": { "id": "…", "name": "Contact – acme.com" },
"submission": {
"id": "b3e04c51-…",
"created_at": "2026-10-04T14:30:00.000Z",
"page_url": "https://acme.example/contact",
"fields": { "name": "Olena", "email": "olena@example.com", "message": "…" },
"is_spam": false
}
}
The dashboard’s “test” button sends the same shape with "event": "webhook.test".
The workflow (6 nodes + 2 dead ends)
- Webhook: POST, path
formgong-contact, Respond = Immediately (the sender waits only 10 s). Raw Body = on. With Raw Body on, the parsedbodyis still there, and the exact bytes are also in binarydata. The signature has to be computed over those bytes, not over re-serialised JSON. - Crypto (HMAC of raw body): action Hmac, Binary File on, property
data, SHA256, encoding hex, output toexpected_signature. The secret lives in a Crypto credential (Hmac Secret), not in the node. - If “Signature valid?”:
'sha256=' + $json.expected_signatureequals$json.headers['x-signature']. False → a NoOp dead end. - If “Real submission?”:
$json.body.eventequalssubmission.created. False (test deliveries) → NoOp. - Google Sheets: Append or Update Row, matching column
submission_id. The sender retries failed deliveries (up to 5 attempts), so matching on the submission id keeps retries from creating duplicate rows. - Telegram: sendMessage with every field, HTML parse mode, values HTML-escaped (so a visitor typing
<b>or_can’t break the message), n8n attribution off.
How I tested it (self-hosted n8n 2.41.6):
| Request | Result |
|---|---|
| Correctly signed submission (Cyrillic, Polish and Turkish characters in the message) | Sheets + Telegram branch ran |
| Same signature, body changed by one word | Dropped at “Signature valid?” |
| Signed with the wrong secret | Dropped |
No X-Signature header |
Dropped |
Correctly signed webhook.test |
Stopped at the test NoOp |
For the Telegram node I pointed the credential at a local mock API to check the exact sendMessage payload. Sheets was only checked up to the auth step (no real Google account in the test).
Setup after import
- Crypto credential → paste the form’s signing secret.
- Google Sheets credential + spreadsheet URL; a sheet named
Leadswith the header rowsubmission_id, created_at, form, name, email, phone, message, page_url. - Telegram credential + chat id.
- Publish, then put the Production URL into the sender.
Gotchas
- The URL must be public HTTPS on port 443:
http://localhost:5678won’t receive anything from a hosted sender. Put n8n behind a TLS proxy and setWEBHOOK_URL. - Header names arrive lower-case in n8n (
x-signature). - The If node’s string compare isn’t constant-time. That’s fine for this use; if you care, do the compare in a Code node with
crypto.timingSafeEqual. - Bad signatures still get a 200, because the node responds immediately. That’s intentional: a fast 200 avoids timeouts and duplicate runs. If you’d rather return 401, switch to a Respond to Webhook node and keep the workflow fast.
Longer write-up with field-mapping expressions: n8n Webhook node for form submissions: setup | Formgong
Happy to adapt it for other destinations (Airtable, a CRM via HTTP Request) if anyone needs that.