# Node

Appelez Formerie à partir de n’importe quel serveur Node avec `fetch`, qui est intégré, sans rien à installer. Cette page s’adresse à Node seul, à Express, à Fastify et à Hono. Votre framework a sa propre page? Commencez par son [guide de démarrage](/fr/guides/framework-entry-points/#votre-framework). Vous n’avez besoin ni d’un paquet Formerie ni d’une clé d’API : l’adresse publique d’un formulaire suffit.

## Soumettre un formulaire

Enregistrez ce code sous `submit.mjs`, puis lancez `node submit.mjs`.

```js
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";

const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    fields: {
      name: "Jane Example",
      email: "jane@example.com",
      message: "Hello from my server",
    },
  }),
  signal: AbortSignal.timeout(10_000),
});
const result = await response.json();
console.log(response.status, result.ok ? result.submissionUid : result.error.code);
```

Remplacez `acme/contact`, sur la ligne indiquée, par vos propres identifiants. Une réussite donne l’état HTTP `202`, avec un `submissionUid` comme `sub_p6dr8wq2j7tkan` : les réponses sont stockées et leur courriel est en file d’attente. Chaque exemple lit deux réglages facultatifs dans l’environnement : `FORMERIE_RUNTIME_URL` pour un nom d’hôte que vous avez branché, et `FORMERIE_FORM` pour les identifiants.

## Avant de commencer

1. [Publiez un formulaire](/fr/guides/publish/), et prenez ses identifiants d’espace de travail et de formulaire dans l’onglet **Publication**. Votre serveur n’a pas besoin de figurer dans les sites autorisés du formulaire.
2. Pour faire des essais sans envoyer de courriel, créez une clé de test dans l’onglet **Tests** du formulaire, et mettez-la dans une variable d’environnement appelée `FORMERIE_TEST_KEY` :

```js
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";

const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    // Keep the key out of your code. A test is stored apart and emails nobody.
    "X-Formerie-Test-Key": process.env.FORMERIE_TEST_KEY,
  },
  body: JSON.stringify({
    fields: { name: "Jane Example", email: "jane@example.com", message: "Just a test" },
  }),
  signal: AbortSignal.timeout(10_000),
});
const result = await response.json();
console.log(response.status, result.ok ? result.submissionUid : result.error.code);
```

Un test revient sous la forme `tst_...`, est stocké à part et n’envoie de courriel à personne. Une mauvaise clé reçoit `TEST_KEY_INVALID`, état HTTP `401`, et rien n’est stocké. Retirez l’en-tête avant la mise en ligne, car une clé laissée en place classe tout comme un test. Consultez [Tester depuis un serveur ou l’intégration continue](/fr/guides/test-your-form/#tester-depuis-un-serveur-ou-lintégration-continue).

## Vérifier des réponses sans soumettre

`validate` vérifie les réponses et ne stocke rien, ce qui vous permet de signaler un problème tôt. Il répond `200` quand elles passent, et `422` avec les mêmes erreurs qu’une soumission quand elles ne passent pas. La soumission finale vérifie toujours tout le formulaire de nouveau.

```js
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";

// Checks answers and stores nothing. With no step, only the fields you send are checked.
async function checkAnswers(fields, step) {
  const response = await fetch(`${RUNTIME_URL}/${FORM}/validate`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(step ? { mode: "step", step, fields } : { mode: "partial", fields }),
    signal: AbortSignal.timeout(10_000),
  });
  const result = await response.json();
  if (!result.ok && result.error.code !== "VALIDATION_FAILED") {
    throw new Error(result.error.code);
  }
  return result;
}
```

Par défaut, seuls les champs que vous envoyez sont vérifiés. Passez une étape pour vérifier plutôt tous ses champs, sur un formulaire [à étapes](/fr/guides/multi-step-forms/). Une étape que le formulaire n’a pas reçoit `BAD_REQUEST`.

## Afficher les erreurs par champ

Les réponses refusées reviennent avec `VALIDATION_FAILED`, état HTTP `422`, et rien n’est stocké. `error.fields` contient les problèmes par champ, et `error.issues` les mêmes, dans l’ordre. Chacun a un `path`, un `code` sur lequel vous fier et un `message` que vous pouvez afficher, dans la langue du formulaire.

```js
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";

async function submitAnswers(fields) {
  const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ fields }),
    signal: AbortSignal.timeout(10_000),
  });
  return response.json();
}

const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);

// Finds the visitor's answer for a path such as contact.email, so the form can show it again.
const answerAt = (answers, path) => path.split(".").reduce((value, part) => (Object.hasOwn(Object(value), part) ? value[part] : ""), answers);

// One input with its answer kept and its first message under it.
function renderField(path, answers, result) {
  const message = result.error?.fields?.[path]?.[0]?.message;
  const error = message ? `<p class="error">${escapeHtml(message)}</p>` : "";
  return `<input name="${escapeHtml(path)}" value="${escapeHtml(answerAt(answers, path))}">${error}`;
}
```

Un champ dans un groupe a un chemin comme `contact.email`, et un champ dans une liste, `guests[0].email` : séparez le chemin pour trouver sa réponse. Consultez [Chemins de champs](/fr/guides/validation/#chemins-de-champs). Réaffichez les réponses du visiteur avec les messages, échappées, pour qu’il n’ait qu’à corriger ce qui ne va pas.

## Réessayer en toute sécurité

Une panne de réseau ne vous dit pas si la soumission est arrivée. Envoyez donc une seule `metadata.idempotencyKey` par soumission, la même à chaque nouvel essai. Formerie la compte et la livre alors une seule fois, et signale une répétition avec `DUPLICATE_SUBMISSION`, état HTTP `409`, qui veut dire que le premier envoi a fonctionné.

```js
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const MAX_ATTEMPTS = 3;
const LONGEST_WAIT = 30;
const TEMPORARY_CODES = new Set(["FORM_MAINTENANCE", "QUEUE_FAILED", "INTERNAL_ERROR"]);

// One attempt. Says what happened, and whether another attempt could help.
async function sendOnce(fields, key) {
  let response;
  try {
    response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ fields, metadata: { idempotencyKey: key } }),
      signal: AbortSignal.timeout(10_000),
    });
  } catch {
    return { outcome: "retry-later", reason: "network", again: true };
  }

  // Only the short-term limit sends Retry-After. A form's own limit can last an hour, so don't wait for it.
  if (response.status === 429) {
    const header = response.headers.get("Retry-After") ?? "";
    const after = /^\d+$/.test(header) ? Number(header) : null;
    return { outcome: "retry-later", reason: "rate-limited", after, again: after !== null && after <= LONGEST_WAIT };
  }

  // Something in front of Formerie may answer without JSON, so look at the status first.
  let result;
  try {
    result = await response.json();
  } catch {
    return { outcome: "retry-later", reason: "bad-response", again: [502, 503, 504].includes(response.status) };
  }

  if (result.ok) {
    return { outcome: "accepted", id: result.submissionUid };
  }
  const { code, message, issues } = result.error;
  if (code === "DUPLICATE_SUBMISSION") {
    // The first send worked, and only its answer was lost.
    return { outcome: "accepted", duplicate: true };
  }
  if (TEMPORARY_CODES.has(code)) {
    return { outcome: "retry-later", reason: code, again: true };
  }
  if (code === "VALIDATION_FAILED") {
    return { outcome: "invalid", code, issues };
  }
  return { outcome: "rejected", code, message };
}

// One key per submission, the same on every retry. Store it with the answers if you retry later.
async function submitWithRetries(fields, key = crypto.randomUUID()) {
  for (let attempt = 1; ; attempt += 1) {
    const { again, ...answer } = await sendOnce(fields, key);
    if (!again || attempt === MAX_ATTEMPTS) {
      return { ...answer, key, attempts: attempt };
    }
    const backoff = Math.random() * Math.min(250 * 2 ** attempt, 4_000);
    await new Promise((resolve) => setTimeout(resolve, Math.max((answer.after ?? 0) * 1_000, backoff)));
  }
}
```

| Réponse | Réessayer? |
| --- | --- |
| Pas de connexion, ou délai dépassé | Oui, avec la même clé |
| `429` avec `Retry-After` | Oui, après cette attente, si elle est courte. La limite à court terme répond avec `Retry-After: 60` |
| `429` sans `Retry-After` | Non. La limite d’un formulaire peut durer une heure : demandez au visiteur de réessayer plus tard |
| `FORM_MAINTENANCE`, `QUEUE_FAILED`, `INTERNAL_ERROR` | Oui, après une courte attente |
| `502`, `503` ou `504` sans JSON | Oui. Quelque chose devant Formerie a répondu |
| Toute autre réponse sans JSON | Non. Gardez la clé, et voyez ce qui s’est passé |
| `DUPLICATE_SUBMISSION` | Non. Traitez-le comme un succès |
| `VALIDATION_FAILED` et tout autre code | Non. La réponse ne changera pas |

- La clé doit être une chaîne non vide. Tout le reste est ignoré, et il n’y a alors aucune vérification des répétitions.
- Une répétition est repérée pendant 300 secondes par défaut, selon la période d’envois répétés du formulaire. Consultez [Ce que couvre la période d’envois répétés](/fr/guides/spam-protection/#ce-que-couvre-la-période-denvois-répétés).
- Cette vérification passe en premier : une clé déjà acceptée reçoit `DUPLICATE_SUBMISSION`, même avec d’autres réponses. Utilisez une nouvelle clé pour chaque nouvelle soumission. Une clé refusée à cause de ses réponses n’a jamais été acceptée : elle peut repartir une fois celles-ci corrigées.
- Un nouvel essai après `QUEUE_FAILED` peut stocker une deuxième copie, que vous verriez sous **Soumissions**. C’est rare.

## Vérification humaine

Si votre formulaire exige une vérification humaine, utilisez vos propres clés, avec votre site dans la liste de domaines du fournisseur et dans les sites autorisés du formulaire. Le widget ajoute son jeton à votre formulaire. Transmettez deux choses :

1. **Le jeton,** comme `verificationToken`, retiré des champs.
2. **L’`Origin` de la requête du visiteur,** parce que Formerie compare le domaine du jeton avec celui-ci. Un serveur n’en envoie pas de lui-même, et la vérification échoue alors avec `TURNSTILE_FAILED`.

```js
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";

// posted is what your form sent. origin is the Origin header of the visitor's request.
async function submitVerified(posted, origin) {
  const { "cf-turnstile-response": verificationToken, ...fields } = posted;
  const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // Without the visitor's Origin, the provider's hostname can't match, and the check fails.
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({ fields, verificationToken }),
    signal: AbortSignal.timeout(10_000),
  });
  return response.json();
}
```

Une `Origin` absente des sites autorisés du formulaire reçoit `FORBIDDEN_ORIGIN`. Ne la transmettez que pour ça : sur tout autre formulaire, elle active une vérification dont votre serveur n’a autrement pas besoin. Le widget a besoin de JavaScript dans le navigateur du visiteur. Consultez [La vérification avec un serveur](/fr/guides/framework-entry-points/#la-vérification-avec-un-serveur).

## Vérifications antipourriel

Les pages hébergées et les intégrations envoient ces métadonnées pour vous. À partir de votre serveur, envoyez-les vous-même, sinon ces vérifications sont sautées.

```js
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";

async function submitWithMetadata(fields, startedAt, visitorId) {
  const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      fields,
      metadata: {
        idempotencyKey: crypto.randomUUID(),
        // When the visitor opened the form, for the minimum submit time check.
        startedAt: startedAt.toISOString(),
        pageUrl: "https://www.example.com/contact",
        // Your own visitor ID, for a limit rule with the deviceToken scope.
        deviceToken: visitorId,
      },
    }),
    signal: AbortSignal.timeout(10_000),
  });
  return response.json();
}
```

| Champ | À quoi il sert |
| --- | --- |
| `idempotencyKey` | La vérification des répétitions, comme ci-dessus |
| `startedAt` | Le moment où le visiteur a ouvert le formulaire, en heure ISO 8601. Envoyé avant le minimum du formulaire, 1 seconde par défaut, il reçoit `SPAM_REJECTED`. Gardez la vraie heure à laquelle vous avez affiché le formulaire, pas celle de cet appel. Une valeur illisible saute la vérification |
| `pageUrl` | La page d’où le formulaire a été envoyé. Stockée seulement s’il s’agit d’une adresse http ou https |
| `deviceToken` | Votre propre identifiant de visiteur, pour une limite par visiteur, ci-dessous |

La vérification du champ caché s’applique quand votre formulaire envoie ce champ, et les liens et la vérification humaine sont vérifiés sur le serveur dans tous les cas. Consultez [Pourriel et limites](/fr/guides/spam-protection/).

## Les limites à partir de votre serveur

Formerie compte les visiteurs par adresse réseau, et d’ici, c’est celle de votre serveur. **Chaque visiteur compte comme votre serveur.**

| Limite | Par défaut | À partir de votre serveur |
| --- | --- | --- |
| Par visiteur | 30 soumissions toutes les 10 minutes | Partagée par tous vos visiteurs. Augmentez-la avant le lancement, ou laissez-la vide pour n’avoir aucune limite, sous **Paramètres**, puis **Limites de soumission** |
| Tout le formulaire | 1 000 soumissions à l’heure | La même. Avec le forfait gratuit, c’est aussi le maximum qu’un formulaire peut accepter |
| Court terme | 120 requêtes à la minute pour chaque formulaire à partir d’une même adresse | Ne peut pas être changée. C’est la seule qui envoie `Retry-After`. `validate` y compte aussi |

Au-delà de l’une d’elles, vous recevez `RATE_LIMITED`, état HTTP `429`. Pour garder une limite par visiteur, une règle avec la portée `deviceToken` compte selon l’identifiant que vous envoyez, comme un hachage de l’adresse du visiteur. Les paramètres de l’application n’offrent pas encore cette règle, alors [écrivez-nous](https://formerie.com/contact/) pour en mettre une en place, et gardez la limite pour tout le formulaire, car quiconque appelle directement le formulaire peut changer son identifiant. Consultez [Les limites avec un serveur](/fr/guides/framework-entry-points/#les-limites-avec-un-serveur) pour le raisonnement, et [Forfaits et limites](/fr/guides/plans-and-limits/) pour les compteurs mensuels.

## La façon la plus courte

`FormerieRuntimeClient`, de `@formerie/client`, fait le même appel avec moins de code, et `validationMessagesByField` classe par champ les messages d’un refus. Ce paquet est en alpha, donc son API peut encore changer. Il ne réessaie pas pour vous : la clé et la boucle ci-dessus restent valables.

```bash
npm install @formerie/client
```

```js
import { FormerieRuntimeClient, validationMessagesByField } from "@formerie/client";

// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const [workspace, form] = FORM.split("/");

const formerie = new FormerieRuntimeClient({
  endpoint: process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com",
});

// Name the fields you send, so a visitor can't add others. Keep the key across retries of one submission.
async function submitContact({ name, email, message }, key = crypto.randomUUID()) {
  return formerie.submit(workspace, form, {
    fields: { name, email, message },
    metadata: { idempotencyKey: key },
  });
}
```

## Express, Fastify et Hono

Ils fonctionnent par l’API, avec l’exemple ci-dessus dans une route : le framework lit le formulaire du visiteur, et votre route appelle Formerie. Chacun a été exécuté avec un backend Formerie local, avec Express 5.3, Fastify 5.12 et Hono 4.13. Chaque route répond `202` avec l’identifiant de la soumission, le même état que Formerie avec les messages par champ, ou `503` si elle ne peut pas joindre Formerie. Un `<form method="POST">` natif peut envoyer à n’importe laquelle, et c’est ce qui permet à un formulaire de fonctionner sans JavaScript.

### Express

```bash
npm install express
```

```js
import express from "express";

const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const app = express();
app.use(express.urlencoded({ extended: false }));

app.post("/contact", async (request, response) => {
  // Name the fields you send, so a visitor can't add others.
  const { name, email, message } = request.body;
  try {
    const answer = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ fields: { name, email, message } }),
      signal: AbortSignal.timeout(10_000),
    });
    const result = await answer.json();
    if (result.ok) {
      response.status(202).json({ id: result.submissionUid });
    } else {
      const errors = Object.fromEntries(
        Object.entries(result.error.fields ?? {}).map(([path, issues]) => [path, issues.map((issue) => issue.message)]),
      );
      response.status(answer.status).json({ code: result.error.code, errors });
    }
  } catch {
    // No connection, a timeout, or an answer that isn't JSON. Keep the visitor's answers on screen.
    response.status(503).json({ code: "TRY_AGAIN" });
  }
});

app.listen(process.env.PORT ?? 3000);
```

### Fastify

Fastify a besoin de `@fastify/formbody` pour lire un formulaire natif.

```bash
npm install fastify @fastify/formbody
```

```js
import formbody from "@fastify/formbody";
import Fastify from "fastify";

const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const app = Fastify();
await app.register(formbody);

app.post("/contact", async (request, reply) => {
  // Name the fields you send, so a visitor can't add others.
  const { name, email, message } = request.body;
  try {
    const answer = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ fields: { name, email, message } }),
      signal: AbortSignal.timeout(10_000),
    });
    const result = await answer.json();
    if (result.ok) {
      return reply.code(202).send({ id: result.submissionUid });
    }
    const errors = Object.fromEntries(
      Object.entries(result.error.fields ?? {}).map(([path, issues]) => [path, issues.map((issue) => issue.message)]),
    );
    return reply.code(answer.status).send({ code: result.error.code, errors });
  } catch {
    // No connection, a timeout, or an answer that isn't JSON. Keep the visitor's answers on screen.
    return reply.code(503).send({ code: "TRY_AGAIN" });
  }
});

await app.listen({ port: Number(process.env.PORT ?? 3000) });
```

### Hono

`@hono/node-server` est ce qui fait rouler Hono sur Node. Sur Cloudflare Workers, laissez-le de côté et utilisez `export default app`. Votre code joint alors Formerie à partir d’une adresse que les Workers partagent, alors lisez [Les limites avec un serveur](/fr/guides/framework-entry-points/#les-limites-avec-un-serveur).

```bash
npm install hono @hono/node-server
```

```js
import { serve } from "@hono/node-server";
import { Hono } from "hono";

const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const app = new Hono();

app.post("/contact", async (context) => {
  // Name the fields you send, so a visitor can't add others.
  const { name, email, message } = await context.req.parseBody();
  try {
    const answer = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ fields: { name, email, message } }),
      signal: AbortSignal.timeout(10_000),
    });
    const result = await answer.json();
    if (result.ok) {
      return context.json({ id: result.submissionUid }, 202);
    }
    const errors = Object.fromEntries(
      Object.entries(result.error.fields ?? {}).map(([path, issues]) => [path, issues.map((issue) => issue.message)]),
    );
    return context.json({ code: result.error.code, errors }, answer.status);
  } catch {
    // No connection, a timeout, or an answer that isn't JSON. Keep the visitor's answers on screen.
    return context.json({ code: "TRY_AGAIN" }, 503);
  }
});

serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });
```

## Bon à savoir

- Gardez `signal: AbortSignal.timeout(10_000)` sur chaque appel, pour qu’une réponse lente ne retienne pas votre page.
- Gardez les clés de test et les réponses hors de vos journaux. Le `requestId` d’une erreur, ou l’en-tête `X-Request-Id`, est ce qu’il faut journaliser et citer quand vous demandez de l’aide.
- Fiez-vous à `error.code`, jamais au message, car un formulaire peut reformuler ses messages et un même état peut couvrir plusieurs codes. [Codes d’erreur](/fr/guides/error-codes/) les énumère tous.

| Avec | Note |
| --- | --- |
| Nommez vos champs | Construisez `fields` à partir des noms que vous attendez, comme le font tous les exemples ici, et non à partir de ce que contient la requête. Un visiteur peut envoyer n’importe quel nom. |
| `Origin` | Dans Node, `fetch` n’en envoie pas à moins que vous l’ajoutiez, et c’est ce qu’il faut partout sauf pour la vérification humaine. |

Les routes, les champs et toutes les erreurs se trouvent dans la [référence de l’API](/fr/guides/api-reference/).
