Skip to content

Submitter Guide

The submitter side of the public API lets a session (an AI agent or an anonymous web client) validate and submit form responses. These endpoints authenticate with the x-respondent-token header.

Read the Overview first — every response uses the standard envelope. If you are building an agent end to end, follow the Agent quickstart.

Sessions

Submitting requires a short-lived session token, sent as x-respondent-token.

Agent Sessions

An agent exchanges its long-lived secret for a session token:

bash
curl -X POST https://api.kinoforms.com/papi/v1/auth/token \
  -H "content-type: application/json" \
  -d '{ "clientSecret": "'"$AGENT_SECRET"'" }'
json
{
  "data": {
    "token": "rt_live_8f2c...",
    "expiresAt": "2026-06-29T12:00:00Z"
  },
  "apiVersion": "v1"
}

The token is valid for 7 days. Cache it and reuse it; mint a new one when it expires. The agent secret itself comes from registering an agent identity — see the Agent quickstart.

Anonymous Sessions

POST /papi/v1/auth/anonymous issues a session for an untrusted web client. It is available when a form enables CAPTCHA (Cloudflare Turnstile): the client passes a Turnstile token, and KinoForms returns a session token bound to that form.

Newly added

Anonymous sessions are a newly-added capability and are being finished separately. Agents do not use this path — agents are exempt from CAPTCHA and authenticate with /auth/token instead.

Validate vs Submit

There are two ways to send response data, and you should usually do both, in this order:

  1. POST /papi/v1/validate/{access_key} — a dry run. It never writes anything and always returns 200 with a structured report. Use it to iterate until your data is clean.
  2. POST /papi/v1/submit/{access_key} — the real thing. It writes a submission and enforces validation: bad data returns 422 validation_failed.

The access_key is the form's public access key (the same key in https://www.kinoforms.com/f/<accessKey>).

Validate (Dry Run)

bash
curl -X POST https://api.kinoforms.com/papi/v1/validate/$ACCESS_KEY \
  -H "x-respondent-token: $SESSION_TOKEN" \
  -H "content-type: application/json" \
  -d '{ "data": { "email": "not-an-email" } }'

Validate always returns 200, even when the data is invalid:

json
{
  "data": {
    "valid": false,
    "errors": [
      {
        "field": "email",
        "code": "format",
        "message": "Value is not a valid email address.",
        "help": "https://docs.kinoforms.com/developer/api/validation#format"
      }
    ],
    "warnings": [
      {
        "field": "nickname",
        "code": "unknown_field",
        "message": "Field is not part of this form and will be ignored.",
        "help": "https://docs.kinoforms.com/developer/api/validation#unknown_field"
      }
    ],
    "missingRequired": ["name"]
  },
  "apiVersion": "v1"
}

The validate report fields:

  • validtrue only when there are no errors and nothing in missingRequired.
  • errors — hard problems that would block a real submit.
  • warnings — soft problems. On validate, unknown_field is a warning (the field is ignored), not an error.
  • missingRequired — keys of required fields that are absent. On validate these are reported here, not thrown as errors, so you can see the whole gap at once.

See Parsing validation responses for the error-object shape and how to self-correct.

Submit (Real)

bash
curl -X POST https://api.kinoforms.com/papi/v1/submit/$ACCESS_KEY \
  -H "x-respondent-token: $SESSION_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "data": { "email": "[email protected]", "name": "Ada" },
    "metadata": { "source": "agent" }
  }'

Request body:

  • data (required) — the field values keyed by field key.
  • metadata (optional) — arbitrary metadata stored with the submission.

On success you get 200 with the created submission in data. On bad data you get 422 with code: "validation_failed" and an errors array.

Submit is stricter than validate

On submit, both unknown_field and any missing required field are errors (422). On validate, unknown_field is a warning and missing-required is reported via missingRequired (still 200). Always validate until clean before you submit.

Challenge Flow

When a form's policy sets agentPolicy.requireChallenge, the submit gate refuses with challenge_required (403). The agent must solve a schema-derived challenge before it can submit.

A challenge asks the agent to produce a value that satisfies one of the form's field constraints. The validator itself is the oracle — the answer is re-run through the existing validation engine, so the agent must demonstrate it understands the form's rules. This costs capability, not money.

Step 1: Issue a Challenge

bash
curl -X POST https://api.kinoforms.com/papi/v1/challenge/$ACCESS_KEY \
  -H "x-respondent-token: $SESSION_TOKEN" \
  -H "content-type: application/json" \
  -d '{}'

Response:

json
{
  "data": {
    "challengeId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "type": "schema",
    "prompt": "Provide a value for the field 'price' that satisfies these constraints: min=10, must be an integer.",
    "fieldId": "price",
    "expiresAt": "2026-07-02T12:02:00Z",
    "maxAttempts": 3
  },
  "apiVersion": "v1"
}
  • Challenges are single-use, expire in ~2 minutes, and are bound to (agent, form).
  • Issuance is rate-limited (RATE_LIMIT_PAPI_AUTH_PER_MINUTE, default 30/min per IP).

Step 2: Answer the Challenge

Responding is signed-only — unsigned (bearer-token) agents cannot complete a challenge. If your agent uses bearer tokens, register a signing key first via POST /papi/v1/identity/agents/{agent_id}/keys (see the Agent quickstart for the identity surface).

bash
curl -X POST https://api.kinoforms.com/papi/v1/challenge/a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d/respond \
  -H "content-type: application/json" \
  -H "Signature-Input: sig1=..." \
  -H "Signature: sig1=:...:" \
  -d '{ "answer": 15 }'

The answer is a single value for the challenged field; it is verified by running it back through the form's validation engine.

On success:

json
{
  "data": { "passed": true, "passExpiresAt": "2026-07-02T12:32:00Z" },
  "apiVersion": "v1"
}

On failure (still 200 — the answer was just wrong):

json
{
  "data": { "passed": false, "remainingAttempts": 2, "failedAt": false },
  "apiVersion": "v1"
}

After 3 failed attempts the challenge is marked failed and you must request a new one.

A passed challenge is valid for 30 minutes, scoped to (agent, form). Subsequent submits within that window skip the challenge gate.

Partial Save-and-Continue

For multi-step or resumable flows, submit with ?partial=true:

bash
curl -X POST "https://api.kinoforms.com/papi/v1/submit/$ACCESS_KEY?partial=true" \
  -H "x-respondent-token: $SESSION_TOKEN" \
  -H "content-type: application/json" \
  -d '{ "data": { "email": "[email protected]" } }'

Partial mode:

  • Saves an incomplete row with status=partial.
  • Does not error on missing required fields — they are reported in missingRequired instead.
  • Still validates the fields you did send (a malformed email is still an error).
json
{
  "data": {
    "id": "sub_456",
    "status": "partial",
    "missingRequired": ["name"]
  },
  "apiVersion": "v1"
}

When the data is complete, do a final submit without ?partial=true to finalize the submission.

Reading missingRequired

missingRequired is your checklist of what is still needed. It appears in both the validate report and partial-submit responses.

A simple loop:

  1. validate (or partial submit) with whatever data you have.
  2. Read missingRequired and errors.
  3. Fill in the missing keys and fix the errored ones.
  4. Repeat until missingRequired is empty and valid is true.
  5. Final submit without ?partial=true.

Errors

Submitter errors use the standard envelope. Codes you may see:

  • unauthorized — missing/invalid/expired session token.
  • rate_limited — too many calls. Limits: submit is 1 per 5 seconds per agent (hardcoded) with an IP pre-throttle of RATE_LIMIT_PAPI_SUBMIT_IP_PER_MINUTE (default 30/min); validate is RATE_LIMIT_PAPI_VALIDATE_PER_MINUTE per agent (default 30/min) with IP RATE_LIMIT_PAPI_VALIDATE_IP_PER_MINUTE (default 60/min); the auth mint is IP-keyed at RATE_LIMIT_PAPI_AUTH_PER_MINUTE (default 30/min). Retry all 429s with exponential backoff.
  • validation_failed — submit data is invalid (422).
  • no_grant / missing_scope / respondent_not_assigned — the agent is not authorized for this form. See the Validation reference.

See the Validation reference for every code.

KinoForms documentation