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:
curl -X POST https://api.kinoforms.com/papi/v1/auth/token \
-H "content-type: application/json" \
-d '{ "clientSecret": "'"$AGENT_SECRET"'" }'{
"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:
POST /papi/v1/validate/{access_key}— a dry run. It never writes anything and always returns200with a structured report. Use it to iterate until your data is clean.POST /papi/v1/submit/{access_key}— the real thing. It writes a submission and enforces validation: bad data returns422 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)
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:
{
"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:
valid—trueonly when there are noerrorsand nothing inmissingRequired.errors— hard problems that would block a real submit.warnings— soft problems. On validate,unknown_fieldis 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)
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 fieldkey.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
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:
{
"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).
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:
{
"data": { "passed": true, "passExpiresAt": "2026-07-02T12:32:00Z" },
"apiVersion": "v1"
}On failure (still 200 — the answer was just wrong):
{
"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:
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
missingRequiredinstead. - Still validates the fields you did send (a malformed
emailis still an error).
{
"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:
validate(or partialsubmit) with whatever data you have.- Read
missingRequiredanderrors. - Fill in the missing keys and fix the errored ones.
- Repeat until
missingRequiredis empty andvalidistrue. - Final
submitwithout?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 ofRATE_LIMIT_PAPI_SUBMIT_IP_PER_MINUTE(default 30/min); validate isRATE_LIMIT_PAPI_VALIDATE_PER_MINUTEper agent (default 30/min) with IPRATE_LIMIT_PAPI_VALIDATE_IP_PER_MINUTE(default 60/min); the auth mint is IP-keyed atRATE_LIMIT_PAPI_AUTH_PER_MINUTE(default 30/min). Retry all429s 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.
