Verify a signed form webhook (HMAC-SHA256) in n8n, then route to Google Sheets + Telegram: importable workflow

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)

  1. Webhook: POST, path formgong-contact, Respond = Immediately (the sender waits only 10 s). Raw Body = on. With Raw Body on, the parsed body is still there, and the exact bytes are also in binary data. The signature has to be computed over those bytes, not over re-serialised JSON.
  2. Crypto (HMAC of raw body): action Hmac, Binary File on, property data, SHA256, encoding hex, output to expected_signature. The secret lives in a Crypto credential (Hmac Secret), not in the node.
  3. If “Signature valid?”: 'sha256=' + $json.expected_signature equals $json.headers['x-signature']. False → a NoOp dead end.
  4. If “Real submission?”: $json.body.event equals submission.created. False (test deliveries) → NoOp.
  5. 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.
  6. 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 Leads with the header row submission_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:5678 won’t receive anything from a hosted sender. Put n8n behind a TLS proxy and set WEBHOOK_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.

Workflow JSON (copy and paste into the n8n canvas)