---
title: "Send one OTP across a batch of envelopes (multi-document signing)"
method: POST
path: "/api/batch/otp"
---

# Send one OTP across a batch of envelopes (multi-document signing)

`POST /api/batch/otp`

## Request

- Base URL: `https://free-sign.com`
- URL: `https://free-sign.com/api/batch/otp`
- Auth: none declared

Multi-document signing (one OTP/passkey ceremony signs several PDFs). The browser creates one envelope per document, then calls this once: it verifies a per-envelope session proof for every item (carried in the body, since one request has only one set of session headers), enforces Turnstile + the per-email cooldown + rate limits ONCE, generates ONE code, and creates one OTP challenge per envelope with that shared code. A single email is sent. Each envelope is then verified individually via POST /api/envelopes/{envelope_id}/otp/verify with the one typed code (each challenge is salted by its envelope id, so the same code verifies all of them). Batch size is capped at BATCH_MAX_DOCS (default 10).

## Request body

- object
  - `email` string, email, required
  - `turnstile_token` string: Cloudflare Turnstile token; required when TURNSTILE_SECRET_KEY is set. Checked once for the whole batch.
  - `items` object[], required: One entry per envelope in the batch (1..BATCH_MAX_DOCS).
    - `envelope_id` string, required
    - `session` object, required: Per-envelope session proof — the same fields the x-fsig-session-* headers carry, over canonicalJson({action:'otp.request', envelope_id, nonce, timestamp}).
      - `signature` string, required: base64url ECDSA P-256 (P1363) signature
      - `nonce` string, required
      - `timestamp` string, date-time, required

## Response `200`

One OTP challenge created per envelope; one email sent.

- object
  - `items` object[], required
    - `envelope_id` string, required
    - `signer_id` string, required
    - `email_hmac` string, required
    - `otp_challenge_id` string, required
  - `expires_at` string, date-time

## Other responses

- `400`: invalid_email / batch_items_required / batch_too_large / batch_item_invalid / batch_duplicate_envelope.
- `401`: A per-item session proof failed (invalid signature/timestamp/action) or its nonce was replayed (session_nonce_replayed) — the whole batch is rejected.
- `403`: turnstile_required (token missing while Turnstile is configured) / turnstile_failed (siteverify rejected the token).
- `404`: not_found — one of the envelopes does not exist.
- `409`: envelope_not_otp_eligible — one of the envelopes is finalized or expired. The response names the offending envelope_id and a `phase` telling you what the rejection cost. phase="precheck" (the up-front gate): nothing was spent — no session nonce, no Turnstile token, no cooldown or rate-limit tick, no challenge row — so drop that document and resend the rest immediately with the SAME proofs and the SAME Turnstile token. phase="race" (the envelope turned terminal mid-flight, after the gate passed it): no code was sent, but this batch's session nonces ARE consumed and one rate-limit tick was charged — plus whatever else the deployment enforces on this route: the Turnstile token when Turnstile is configured, and the per-email cooldown unless OTP_COOLDOWN_SECONDS is 0 or the address is the @smoke.free-sign.com exemption. So the resend needs freshly signed proofs, plus a freshly solved challenge and a wait of OTP_COOLDOWN_SECONDS (default 30) wherever those two are in force; resending sooner answers 429 otp_cooldown, which on this path means no code has been sent yet, not that one was just sent. The sibling challenge rows written in the same transaction are rolled back, leaving any previously delivered code still valid.
- `429`: otp_cooldown / otp_rate_limited (counted once for the batch). As on the per-envelope route, otp_cooldown only says a request was accepted recently — after a `race` 409 or a failed send it is charged with no code delivered.
- `502`: otp_email_failed — the upstream email provider (Mailgun) rejected the send.
- `503`: turnstile_unavailable (Cloudflare siteverify outage — fails closed) / session_store_unavailable (the session-nonce store was unreachable; retry the same nonce) / otp_delivery_unconfigured (email delivery is not configured on a production host).

---

[API](https://skmtc.dev/free-sign/apis/freesign-free-e-signature-api.md) · [All operations](https://skmtc.dev/free-sign/apis/freesign-free-e-signature-api/llms.txt) · [OpenAPI document](https://skmtc.dev/free-sign/apis/freesign-free-e-signature-api/revisions/5d8e9854ab85?raw)
