# Webhooks

Send every new response to your own systems, the moment it arrives. A webhook is a signed `POST` to an address you choose, beside your email notifications and independent of them.

Using a tool instead of your own server? Go straight to [n8n](/guides/n8n/), [Zapier](/guides/zapier/) or [Make](/guides/make/).

## Add a webhook

1. Open your form, then **Delivery**, and find **Webhooks**.
2. Choose **Add a webhook**, give it a name, and paste your address. It must be public and start with `https://`.
3. Copy the **signing secret**. It's shown only this once.
4. Choose **Send test**. It sends made-up answers with `"test": true`, and shows you what your server answered.

Each form can have up to 5 webhooks, and a workspace up to 20, on every plan. Adding a webhook or changing its address needs a recent sign-in, and emails the workspace's owners and admins.

## What you receive

```http
POST /your/path HTTP/1.1
content-type: application/json
user-agent: Formerie-Webhooks/1
webhook-id: evt_5n7hq2kz8tm4xc
webhook-timestamp: 1793577600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
formerie-event: submission.created
formerie-delivery: whd_7d2k4m9pq3rstv
formerie-attempt: 1
```

```json
{
  "version": 1,
  "id": "evt_5n7hq2kz8tm4xc",
  "type": "submission.created",
  "timestamp": "2026-11-02T18:40:00.000Z",
  "test": false,
  "data": {
    "workspace": { "id": "ws_2kq8m4tz7hn3xd" },
    "form": { "id": "fm_t77txbvg43nmar", "name": "Contact", "version": 3 },
    "submission": {
      "id": "sub_9f3kq7tz2hm8nw",
      "receivedAt": "2026-11-02T18:39:59.000Z",
      "language": "fr-CA",
      "overPlan": false,
      "origin": "https://acme.example",
      "pageUrl": "https://acme.example/contact"
    },
    "fields": {
      "name": "Marie",
      "contact": { "email": "marie@example.com" },
      "topic": "sales"
    },
    "answers": [
      { "path": "name", "label": "Nom", "text": "Marie", "value": "Marie" },
      { "path": "contact.email", "label": "Contact / Courriel", "text": "marie@example.com", "value": "marie@example.com" },
      { "path": "topic", "label": "Sujet", "text": "Ventes", "value": "sales" }
    ]
  }
}
```

| Key | What it is |
| --- | --- |
| `version` | The shape of this body. It changes only for a breaking change. New keys can appear at any time, so ignore the ones you don't know |
| `id` | The event's ID, the same on every try and resend. Use it to drop repeats |
| `type` | `submission.created`, for a new real response. It's the only event today |
| `timestamp` | When this body was built |
| `test` | `true` only for **Send test**, whose answers are made up |
| `data.fields` | The answers, after your [cleaning rules](/guides/cleaning-answers/), by field name, nested as your form is |
| `data.answers` | The same answers as a list, each with its label in the visitor's language, and `text`, which is how the value reads: a choice's label, or Yes and No |
| `data.submission.language` | The language the visitor used |
| `data.submission.overPlan` | `true` when the response arrived past what your plan includes that month. It's for your information: the response is stored and sent like any other |

**Never sent:** verification tokens, spam details, the visitor's IP address or browser, your recipients, or any field you set up as a password input.

**Test entries don't fire webhooks.** Entries from a test key, a test link or `localhost` are stored apart and send nothing, whatever your webhooks are set to. Only **Send test** reaches your address during testing.

## Check the signature

Check it on every request, so nobody can send you fake responses. Formerie follows [Standard Webhooks](https://www.standardwebhooks.com/), a public specification:

1. Join `webhook-id`, `webhook-timestamp` and the raw body with dots: `id.timestamp.body`.
2. Decode your secret from base64, without its `whsec_` prefix, and use it as the key for HMAC-SHA256 of that text.
3. Encode the result in base64, and compare it in constant time with the `v1,` values in `webhook-signature`. There can be two, separated by a space, while you change secrets. One match is enough.
4. Refuse a `webhook-timestamp` more than 5 minutes from now, so a captured request can't be replayed.

Use the raw body, exactly as received. Parsing and re-encoding JSON changes it, and the signature won't match.

**Node**

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyFormerieWebhook(secret, id, timestamp, signatures, rawBody) {
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();

  return signatures.split(" ").some((signature) => {
    const [version, value] = signature.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

**Python**

```python
import base64
import hashlib
import hmac
import time


def verify_formerie_webhook(secret, id, timestamp, signatures, raw_body):
    if not id or not timestamp or not signatures:
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    content = f"{id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, content, hashlib.sha256).digest()).decode()

    return any(
        signature.startswith("v1,") and hmac.compare_digest(signature[3:].encode(), expected.encode())
        for signature in signatures.split(" ")
    )
```

`raw_body` is bytes. In Flask it's `request.get_data()`, and in Django `request.body`.

**PHP**

```php
<?php

function verifyFormerieWebhook(string $secret, string $id, string $timestamp, string $signatures, string $rawBody): bool
{
    if ($id === '' || $timestamp === '' || $signatures === '') {
        return false;
    }
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $key = base64_decode(preg_replace('/^whsec_/', '', $secret));
    $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));

    foreach (explode(' ', $signatures) as $signature) {
        if (str_starts_with($signature, 'v1,') && hash_equals($expected, substr($signature, 3))) {
            return true;
        }
    }
    return false;
}
```

`$rawBody` is `file_get_contents('php://input')`, and the headers are `$_SERVER['HTTP_WEBHOOK_ID']` and so on.

**Go**

```go
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"math"
	"strconv"
	"strings"
	"time"
)

func VerifyFormerieWebhook(secret, id, timestamp, signatures string, rawBody []byte) bool {
	if id == "" || timestamp == "" || signatures == "" {
		return false
	}
	sent, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-sent)) > 300 {
		return false
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return false
	}

	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + timestamp + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)

	for _, signature := range strings.Split(signatures, " ") {
		value, found := strings.CutPrefix(signature, "v1,")
		if !found {
			continue
		}
		given, err := base64.StdEncoding.DecodeString(value)
		if err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}
```

**Ruby**

```ruby
require "base64"
require "openssl"

def verify_formerie_webhook(secret, id, timestamp, signatures, raw_body)
  return false if [id, timestamp, signatures].any? { |value| value.nil? || value.empty? }
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
  digest = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{raw_body}")
  expected = Base64.strict_encode64(digest)

  signatures.split(" ").any? do |signature|
    signature.start_with?("v1,") && OpenSSL.secure_compare(signature.delete_prefix("v1,"), expected)
  end
end
```

Each sample above is run against real signatures, good and bad, before it's published here. You can also use a [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries), since the secret and headers are in its format.

## Answer quickly, and expect repeats

Answer with any `2xx` within 15 seconds. Do slow work afterward. Anything else counts as a failed try:

| Your server answers | What happens |
| --- | --- |
| `2xx` | Delivered |
| Anything else, a timeout, or no connection | Tried again. See below |
| `3xx` | Failed, and tried again. **Redirects are never followed**, so update the address instead |
| `410 Gone` | The webhook pauses right away, since you've said this address is finished |
| `retry-after` beside a failure | The next try waits at least that long, up to 12 hours |

A response gets 8 tries over about two days: at once, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, with a little randomness. Each try has a new `webhook-timestamp` and signature.

**A delivery can arrive twice,** because no system can rule that out. Keep the `webhook-id` of what you've handled, and skip one you've seen. **Resend** keeps the same ID for this reason.

A busy webhook is held to 120 deliveries a minute. Beyond that they wait a minute, without losing a try.

## When it keeps failing

Under each webhook, **Deliveries** shows every response's status, and each try's status code and time. Nothing there holds the answers themselves. You can **Resend** any delivery for 30 days.

If a delivery uses every try, the workspace's owners and admins get an email, at most one a day for each webhook. After 3 such deliveries in a row, the webhook **pauses** and they're told. While it's paused:

- Your responses are safe, and nothing is sent.
- New responses wait, for up to 30 days.
- **Send test** still works, so you can check a fix.
- **Resume** sends everything that waited from the last 7 days. Anything older stays listed, and **Resend** still sends it.

A passing test or delivery starts the count again.

## Change your secret

Choose **Change secret**, and copy the new one. For 24 hours, each request is signed with both, so you can update your server with no gap. If the old one leaked, choose **End the old one now**.

## Which addresses work

Formerie checks your address when you save it and again before each send, on our servers:

- It must use `https`, on the standard port, with no username or password in it.
- It must be a name that resolves on the public internet. A private, loopback, link-local or cloud metadata address is refused, by number or by what the name resolves to.
- It can't be on `formerie.com` or be a form's own custom domain.

Treat the address as a secret if it holds a token, as automation tools' addresses do. After saving, Formerie shows only the start of it, and never holds it in logs. To change it, paste a new one.

## Where your data goes

A webhook sends your visitors' answers to the address you chose, wherever that is, and you're responsible for that system. Mention it in your own privacy notice if it applies. Removing a response here also ends any retries, and removes its delivery log. A copy your server already received is yours to remove.

## From an AI assistant (coming soon)

Once [MCP](/guides/quickstart-ai/) is open to everyone, an assistant connected through it can read a form's webhooks and deliveries, send a test, resend, pause and resume. Adding a webhook, changing its address, changing its secret and removing it stay in the app, because an address is where your answers go. A secret is never shown to an assistant.
