# Check answers with your server

Ask your own system whether a response is right before Formerie accepts it: a real order number, stock left, a free slot, a known account. Your form's own rules and [formulas](/guides/formulas/) handle everything they can see. This is for what only your system knows.

It runs once, when someone submits, after spam and verification checks and your form's own rules. So your server only hears from responses that would otherwise be accepted. Checking one answer while someone types doesn't call it, and neither does moving between steps.

## Set it up

1. Open your form, then **Delivery**, and find **Check answers with your server**.
2. Choose **Set up a check**, and paste your address. It must be public and start with `https://`.
3. Copy the **signing secret**. It's shown only this once.
4. Have your server answer the confirmation below, and choose **Confirm the address**.
5. Choose **Send test** to see what your server says, and what a visitor would see.
6. Choose **Turn on**.

Setting an address needs a recent sign-in, and emails the workspace's owners and admins. Each form can have one check.

## What your server receives

```http
POST /formerie/check HTTP/1.1
content-type: application/json
user-agent: Formerie-Webhooks/1
webhook-id: evt_3f9a1c07d2b84e6fa05c7d19be42
webhook-timestamp: 1793577600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
formerie-event: submission.validate
formerie-delivery: wvc_0196f2a7c41e7b
```

```json
{
  "version": 1,
  "id": "evt_3f9a1c07d2b84e6fa05c7d19be42",
  "type": "submission.validate",
  "timestamp": "2026-11-02T18:40:00.000Z",
  "test": false,
  "data": {
    "workspace": { "id": "ws_2kq8m4tz7hn3xd" },
    "form": { "id": "fm_t77txbvg43nmar", "name": "Commande", "version": 3 },
    "submission": {
      "language": "fr-CA",
      "origin": "https://acme.example",
      "pageUrl": "https://acme.example/commande"
    },
    "fields": { "order_number": "A-1009" },
    "answers": [
      { "path": "order_number", "label": "Numéro de commande", "text": "A-1009", "value": "A-1009" }
    ]
  }
}
```

It's the same shape as a [webhook](/guides/webhooks/), with no submission ID, since nothing is stored yet.

- **Passwords, verification tokens, IP addresses and spam details are never sent.**
- **`language`** is the visitor's language, so you can answer in it.
- **`test`** is `true` for a test. See [Tests](#tests).
- **`origin` and `pageUrl`** can be turned off. You can also send only the answers your check needs: an order check doesn't need the whole message.

## Check the signature

It's signed the same way as a webhook, so use the code in [Check the signature](/guides/webhooks/#check-the-signature). Do this first, on the raw body.

## What to answer

Answer `200` with JSON, either of these:

```json
{ "ok": true }
```

```json
{
  "ok": false,
  "errors": [
    { "path": "order_number", "code": "ORDER_NOT_FOUND", "message": "We can't find this order." }
  ],
  "message": "Please check your answers."
}
```

- **`path`** names one of the form's fields, and the message appears beside it. Anything else shows at the top of the form.
- **`code`** is yours, in capitals, such as `ORDER_NOT_FOUND`. A code that looks like one of Formerie's becomes `WEBHOOK_REJECTED`.
- **`message`** is plain text in the visitor's language, up to 500 characters. Control characters and angle brackets are taken out, and there's no way to send a link or formatting. With no message, the visitor sees the form's usual wording.
- **At most 50 errors** are kept, and your answer can be up to 64 KB.

Your answer can only say yes or no. It can't change a value, add a field or choose who gets an email.

The visitor gets a normal `VALIDATION_FAILED`, the same as for a rule on your form, and nothing is stored.

Only `"ok": false` with a `200` refuses. A `400` or an error page counts as your server not answering, so a broken page can't block your visitors.

## Confirm the address

To confirm the address, Formerie sends a signed `{ "type": "webhook.verify", "challenge": "..." }`. Answer `200` with `{ "challenge": "the same value" }`. Changing the address turns the check off until the new one is confirmed. This shows the address is built for this, and nobody can aim Formerie at someone else's server.

## Sample

Check the signature first, then build your answer like this. Each of these is run against real requests.

**Node**

```javascript
const KNOWN_ORDERS = new Set(["A-1009"]);

export function answerFormerie(rawBody) {
  const event = JSON.parse(rawBody);
  if (event.type === "webhook.verify") return { challenge: event.challenge };

  if (KNOWN_ORDERS.has(event.data.fields.order_number)) return { ok: true };

  const french = event.data.submission.language.startsWith("fr");
  return {
    ok: false,
    errors: [{
      path: "order_number",
      code: "ORDER_NOT_FOUND",
      message: french ? "Nous ne trouvons pas cette commande." : "We can't find this order.",
    }],
  };
}
```

**Python**

```python
import json

KNOWN_ORDERS = {"A-1009"}


def answer_formerie(raw_body):
    event = json.loads(raw_body)
    if event["type"] == "webhook.verify":
        return {"challenge": event["challenge"]}

    if event["data"]["fields"].get("order_number") in KNOWN_ORDERS:
        return {"ok": True}

    french = event["data"]["submission"]["language"].startswith("fr")
    return {
        "ok": False,
        "errors": [{
            "path": "order_number",
            "code": "ORDER_NOT_FOUND",
            "message": "Nous ne trouvons pas cette commande." if french else "We can't find this order.",
        }],
    }
```

**PHP**

```php
function answerFormerie(string $rawBody): array
{
    $event = json_decode($rawBody, true, 32, JSON_THROW_ON_ERROR);
    if ($event['type'] === 'webhook.verify') {
        return ['challenge' => $event['challenge']];
    }

    if (in_array($event['data']['fields']['order_number'] ?? null, ['A-1009'], true)) {
        return ['ok' => true];
    }

    $french = str_starts_with($event['data']['submission']['language'], 'fr');
    return [
        'ok' => false,
        'errors' => [[
            'path' => 'order_number',
            'code' => 'ORDER_NOT_FOUND',
            'message' => $french ? 'Nous ne trouvons pas cette commande.' : "We can't find this order.",
        ]],
    ];
}
```

**Go**

```go
package formerie

import (
	"encoding/json"
	"strings"
)

type event struct {
	Type      string `json:"type"`
	Challenge string `json:"challenge"`
	Data      struct {
		Fields     map[string]any `json:"fields"`
		Submission struct {
			Language string `json:"language"`
		} `json:"submission"`
	} `json:"data"`
}

var knownOrders = map[string]bool{"A-1009": true}

func AnswerFormerie(rawBody []byte) (map[string]any, error) {
	var e event
	if err := json.Unmarshal(rawBody, &e); err != nil {
		return nil, err
	}
	if e.Type == "webhook.verify" {
		return map[string]any{"challenge": e.Challenge}, nil
	}

	if order, _ := e.Data.Fields["order_number"].(string); knownOrders[order] {
		return map[string]any{"ok": true}, nil
	}

	message := "We can't find this order."
	if strings.HasPrefix(e.Data.Submission.Language, "fr") {
		message = "Nous ne trouvons pas cette commande."
	}
	return map[string]any{
		"ok": false,
		"errors": []map[string]string{{
			"path":    "order_number",
			"code":    "ORDER_NOT_FOUND",
			"message": message,
		}},
	}, nil
}
```

**Ruby**

```ruby
require "json"

KNOWN_ORDERS = ["A-1009"].freeze

def answer_formerie(raw_body)
  event = JSON.parse(raw_body)
  return { challenge: event["challenge"] } if event["type"] == "webhook.verify"

  return { ok: true } if KNOWN_ORDERS.include?(event.dig("data", "fields", "order_number"))

  french = event.dig("data", "submission", "language").start_with?("fr")
  {
    ok: false,
    errors: [{
      path: "order_number",
      code: "ORDER_NOT_FOUND",
      message: french ? "Nous ne trouvons pas cette commande." : "We can't find this order.",
    }],
  }
end
```

## Keep it quick and read-only

Visitors wait for your answer, up to 3 seconds by default. You can choose from half a second to 5 seconds. That time covers everything, including reading your answer. Formerie calls once, and never retries while someone waits.

**A check should only read.** If it reserves something, use `webhook-id` to recognise a repeat: a visitor's retry of the same submit has the same one. Don't count on the response being accepted afterward, since the form may be full. To act on an accepted response, use a [webhook](/guides/webhooks/).

## If your server doesn't answer

Taking too long, an error status, a redirect, or anything that isn't the JSON above all count as not answering. Redirects are never followed. You choose what happens:

- **Accept the response anyway**, the default. It goes out as usual, and the entry is marked **Not checked**. Best for most forms.
- **Keep it, and wait for me.** The visitor sees a normal success. Nothing goes out, no email and no webhook, until you send it from **Waiting to send**. Sending it doesn't check it again. It keeps its place if the form has a limit.
- **Ask the visitor to try again.** Nothing is kept, and the visitor sees `VALIDATION_UNAVAILABLE`, with their answers still in the form. Use it only when accepting without the check is worse, such as limited stock or single-use codes, because a response is lost if they don't come back.

After 10 failures in a row over at least a minute, Formerie stops asking for 5 minutes, so visitors don't each wait on a server that's down. Then it tries one call at a time, waiting longer each time, up to an hour. Owners and admins get an email, at most once a day.

If your server hasn't answered for 7 days and you chose to accept, the check turns off, and you get an email. If you chose to keep or refuse, it stays on.

## Tests

A test can reach your real server, so it always says when it did:

- **Send test** on the screen sends made-up answers.
- **A try link, a test key or the app** send what was typed.
- **A test from `localhost` or a test website doesn't call your server,** since anyone can claim to be one. It says it would have.

Each call has `"test": true`, signed the same way. **If your check changes anything, skip that when `test` is `true`.** A test is stored as a test whatever your server does, unless it refuses, which shows the refusal.

The answer to a test holds `test.validationWebhook`, saying whether your server was called and what it said.

## See how it's doing

The screen shows the last 24 hours: how many were asked, fine, refused and not checked. It also lists recent problems, each with its time, reason and status code. Formerie keeps counts for 30 days and problems for 14. It never keeps your server's answer, or what was sent.

If your server refuses more than half of at least 20 responses in an hour, owners and admins get an email, at most once a day. That may be what you want, or it may be a bug costing you responses.

## Change your secret

**Change the secret** gives you a new one, and both sign for 24 hours, so you can update your server without a gap. If it leaked, choose to end the old one now.

## Your privacy notice

Your form sends these answers to your server before accepting them, encrypted on the way. You choose the server, and what's on it is yours to look after. Your privacy notice should say so.

## From an AI assistant (coming soon)

Once [MCP](/guides/quickstart-ai/) is open to everyone, an assistant connected through it can read a check's settings and recent problems, and send a test with made-up answers. Setting the address, turning it on or off, choosing what happens when it's down and changing the secret are done in the app, since an address is where answers go.

## Plans

Available on every plan for now.
