Passer au contenu

cURL

Appelez Formerie à partir d’un terminal, d’une tâche cron ou d’une étape d’intégration continue avec curl et jq. 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

Collez ce code dans un terminal Bash.

Shell
RUNTIME_URL="${FORMERIE_RUNTIME_URL:-https://forms.formerie.com}"
# Your form. This is the only line that names it.
FORM="${FORMERIE_FORM:-acme/contact}"

curl --silent --show-error --max-time 10 \
  --request POST "$RUNTIME_URL/$FORM/submit" \
  --header "Content-Type: application/json" \
  --data '{
    "fields": {
      "name": "Jane Example",
      "email": "jane@example.com",
      "message": "Hello from my server"
    }
  }'

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, 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 :
Shell
RUNTIME_URL="${FORMERIE_RUNTIME_URL:-https://forms.formerie.com}"
# Your form. This is the only line that names it.
FORM="${FORMERIE_FORM:-acme/contact}"

# Keep the key out of your scripts. A test is stored apart and emails nobody.
curl --silent --show-error --max-time 10 \
  --request POST "$RUNTIME_URL/$FORM/submit" \
  --header "Content-Type: application/json" \
  --header "X-Formerie-Test-Key: $FORMERIE_TEST_KEY" \
  --data '{
    "fields": {
      "name": "Jane Example",
      "email": "jane@example.com",
      "message": "Just a test"
    }
  }'

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.

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.

Shell
RUNTIME_URL="${FORMERIE_RUNTIME_URL:-https://forms.formerie.com}"
# Your form. This is the only line that names it.
FORM="${FORMERIE_FORM:-acme/contact}"

# Stores nothing. Only the fields you send are checked.
curl --silent --show-error --max-time 10 \
  --request POST "$RUNTIME_URL/$FORM/validate" \
  --header "Content-Type: application/json" \
  --data '{
    "mode": "partial",
    "fields": {
      "email": "jane@example.com",
      "message": "hi"
    }
  }'

Pour un formulaire à étapes, nommez l’étape, et tous ses champs sont vérifiés :

Shell
RUNTIME_URL="${FORMERIE_RUNTIME_URL:-https://forms.formerie.com}"
# Your form. This is the only line that names it.
FORM="${FORMERIE_FORM:-acme/contact}"

# Checks every field of that step, and nothing else.
curl --silent --show-error --max-time 10 \
  --request POST "$RUNTIME_URL/$FORM/validate" \
  --header "Content-Type: application/json" \
  --data '{
    "mode": "step",
    "step": "you",
    "fields": {
      "name": "Jane Example",
      "email": "jane@example.com"
    }
  }'

Par défaut, seuls les champs que vous envoyez sont vérifiés. 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.

Shell
# $answers holds the visitor's answers as JSON, and $shown is the path of the field to show.
RUNTIME_URL="${FORMERIE_RUNTIME_URL:-https://forms.formerie.com}"
# Your form. This is the only line that names it.
FORM="${FORMERIE_FORM:-acme/contact}"

result=$(curl --silent --show-error --max-time 10 \
  --request POST "$RUNTIME_URL/$FORM/submit" \
  --header "Content-Type: application/json" \
  --data "$(jq -n --argjson fields "$answers" '{fields: $fields}')")

# One line for each problem: the field's path, then its message.
echo "$result" | jq -r '.error.issues[]? | "\(.path): \(.message)"'

# The answer at a path such as contact.email, and its first message, both safe for HTML.
answer=$(echo "$answers" | jq -r --arg path "$shown" 'getpath($path | split(".")) // "" | @html')
message=$(echo "$result" | jq -r --arg path "$shown" '.error.fields[$path][0].message // "" | @html')
html="<input name=\"$shown\" value=\"$answer\"><p class=\"error\">$message</p>"

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. 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é.

Shell
# Takes a key to reuse as its first argument, and $message holds the answer to send.
RUNTIME_URL="${FORMERIE_RUNTIME_URL:-https://forms.formerie.com}"
# Your form. This is the only line that names it.
FORM="${FORMERIE_FORM:-acme/contact}"
MAX_ATTEMPTS=3
LONGEST_WAIT=30

# One key per submission, the same on every retry. Keep it with the answers if you retry later.
key="${1:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)}"
body=$(jq -n --arg message "$message" --arg key "$key" '{
  fields: {name: "Jane Example", email: "jane@example.com", message: $message},
  metadata: {idempotencyKey: $key}
}')
headers=$(mktemp)
trap 'rm -f "$headers"' EXIT

answer() { # outcome-and-details as jq arguments
  jq -cn --arg key "$key" --argjson attempts "$attempt" "$@" '$ARGS.named'
}

pause() { # a little longer each time, and not after the last attempt
  if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then
    sleep "$attempt"
  fi
}

for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
  if ! response=$(curl --silent --max-time 10 --dump-header "$headers" --write-out '\n%{http_code}' \
    --request POST "$RUNTIME_URL/$FORM/submit" \
    --header "Content-Type: application/json" \
    --data "$body"); then
    reason="network"
    pause
    continue
  fi

  status="${response##*$'\n'}"
  result="${response%$'\n'*}"
  after=$(tr -d '\r' <"$headers" | awk 'tolower($1) == "retry-after:" { print $2 }')

  # Only the short-term limit sends Retry-After. A form's own limit can last an hour, so don't wait for it.
  if [ "$status" = "429" ]; then
    reason="rate-limited"
    if [ -n "$after" ] && [ "$after" -le "$LONGEST_WAIT" ] && [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then
      sleep "$after"
      continue
    fi
    answer --arg outcome retry-later --arg reason "$reason" --arg after "$after"
    exit 0
  fi

  # Something in front of Formerie may answer without JSON.
  if ! code=$(echo "$result" | jq -er 'if .ok then "OK" else .error.code end' 2>/dev/null); then
    reason="bad-response"
    case "$status" in
      502 | 503 | 504) pause; continue ;;
    esac
    answer --arg outcome retry-later --arg reason "$reason"
    exit 0
  fi

  case "$code" in
    OK)
      answer --arg outcome accepted --arg id "$(echo "$result" | jq -r .submissionUid)"
      exit 0
      ;;
    DUPLICATE_SUBMISSION)
      # The first send worked, and only its answer was lost.
      answer --arg outcome accepted --argjson duplicate true
      exit 0
      ;;
    FORM_MAINTENANCE | QUEUE_FAILED | INTERNAL_ERROR)
      reason="$code"
      pause
      continue
      ;;
    VALIDATION_FAILED)
      answer --arg outcome invalid --arg code "$code" --argjson issues "$(echo "$result" | jq -c .error.issues)"
      exit 0
      ;;
    *)
      answer --arg outcome rejected --arg code "$code" --arg message "$(echo "$result" | jq -r .error.message)"
      exit 0
      ;;
  esac
done

attempt=$MAX_ATTEMPTS
answer --arg outcome retry-later --arg reason "$reason"
RéponseRéessayer?
Pas de connexion, ou délai dépasséOui, avec la même clé
429 avec Retry-AfterOui, après cette attente, si elle est courte. La limite à court terme répond avec Retry-After: 60
429 sans Retry-AfterNon. La limite d’un formulaire peut durer une heure : demandez au visiteur de réessayer plus tard
FORM_MAINTENANCE, QUEUE_FAILED, INTERNAL_ERROROui, après une courte attente
502, 503 ou 504 sans JSONOui. Quelque chose devant Formerie a répondu
Toute autre réponse sans JSONNon. Gardez la clé, et voyez ce qui s’est passé
DUPLICATE_SUBMISSIONNon. Traitez-le comme un succès
VALIDATION_FAILED et tout autre codeNon. 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.
  • 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.
Shell
# $token is what your form sent as cf-turnstile-response, and $origin is the visitor's Origin header.
RUNTIME_URL="${FORMERIE_RUNTIME_URL:-https://forms.formerie.com}"
# Your form. This is the only line that names it.
FORM="${FORMERIE_FORM:-acme/contact}"

# The token goes apart from the fields. Without the visitor's Origin, the provider's hostname can't match.
origin_header=()
if [ -n "$origin" ]; then
  origin_header=(--header "Origin: $origin")
fi

curl --silent --show-error --max-time 10 \
  --request POST "$RUNTIME_URL/$FORM/submit" \
  --header "Content-Type: application/json" \
  "${origin_header[@]}" \
  --data "$(jq -n --arg token "$token" '{
    fields: {name: "Jane Example", email: "jane@example.com", message: "Hello from my server"},
    verificationToken: $token
  }')"

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.

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.

Shell
# $started_at is an ISO 8601 time, such as 2026-11-01T12:00:00Z, and $visitor_id is your own ID for the visitor.
RUNTIME_URL="${FORMERIE_RUNTIME_URL:-https://forms.formerie.com}"
# Your form. This is the only line that names it.
FORM="${FORMERIE_FORM:-acme/contact}"

# startedAt: when the visitor opened the form, for the minimum submit time check.
# deviceToken: your own visitor ID, for a limit rule with the deviceToken scope.
curl --silent --show-error --max-time 10 \
  --request POST "$RUNTIME_URL/$FORM/submit" \
  --header "Content-Type: application/json" \
  --data "$(jq -n \
    --arg key "$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)" \
    --arg started_at "$started_at" \
    --arg visitor_id "$visitor_id" '{
      fields: {name: "Jane Example", email: "jane@example.com", message: "Hello from my server"},
      metadata: {
        idempotencyKey: $key,
        startedAt: $started_at,
        pageUrl: "https://www.example.com/contact",
        deviceToken: $visitor_id
      }
    }')"
ChampÀ quoi il sert
idempotencyKeyLa vérification des répétitions, comme ci-dessus
startedAtLe 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
pageUrlLa page d’où le formulaire a été envoyé. Stockée seulement s’il s’agit d’une adresse http ou https
deviceTokenVotre 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.

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.

LimitePar défautÀ partir de votre serveur
Par visiteur30 soumissions toutes les 10 minutesPartagé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 formulaire1 000 soumissions à l’heureLa même. Avec le forfait gratuit, c’est aussi le maximum qu’un formulaire peut accepter
Court terme120 requêtes à la minute pour chaque formulaire à partir d’une même adresseNe 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 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 pour le raisonnement, et Forfaits et limites pour les compteurs mensuels.

Bon à savoir

  • Gardez --max-time sur chaque appel, car curl attend aussi longtemps qu’il le faut sinon.
  • 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 les énumère tous.
AvecNote
--failLaissez-le de côté. Il cache le corps d’un 422, là où se trouvent les erreurs de champ.
--retryIl ne connaît ni DUPLICATE_SUBMISSION ni la durée de la limite d’un formulaire : la boucle de cette page est plus sûre.
jqConstruisez le corps avec jq -n, comme ces exemples, pour qu’un guillemet dans une réponse ne brise pas le JSON.

Les routes, les champs et toutes les erreurs se trouvent dans la référence de l’API.