Forms API
Build forms in the dashboard and post submissions from your own website or app. Every submission becomes a ticket — with per-person limits, receipts, and routing.
Overview
The Forms API turns your ticketing setup into a form service. You build a form in the dashboard — a contact form, a refund request, a campaign entry, a sign-up — and post submissions to it from your own website or app. Every submission becomes a ticket in the same inbox your agents already work, with the answers attached, a confirmation email if you want one, and the same departments, SLAs and routing rules as any other ticket.
It is deliberately form-shaped, not ticket-shaped: no subject to invent, no required end-user id for anonymous visitors, first-class email so the receipt can always go out, and an explicit locale so the receipt is in the right language.
Creates support work
The default. Submissions enter the ticket queue: SLA clocks start and department members are notified. Right for “Contact us” and “Request a refund”.
Data only
Still writes a ticket row (one source of truth) but skips SLA and notifications. A 5,000-entry campaign must not put 5,000 items in the agent queue.
How It Works
- Build the form in the dashboard under Tickets → Forms: add fields, pick the department it routes to, choose whether it creates support work, optionally cap submissions per person, then Activate it.
- Your backend calls
GET /external/v1/forms/:form_keyto fetch the field definitions and renders the form on your site from that source of truth — so editing the form in the dashboard never silently drifts from what your site shows. - Your site posts to your backend, which relays to
POST /external/v1/forms/:form_key/submissionswith the answers keyed by field id. - We validate, enforce the per-person limit, create the ticket, snapshot the answers on it, send the receipt (if configured), and return the ticket number.
nextcap_tk_ key must never reach a browser. Requests carrying browser Sec-Fetch-* headers are rejected with 403. Your public form posts to your own backend; your backend calls us.Authentication
Use your Tickets API key, minted in the dashboard at Settings → API Keys → Tickets API. Keys may be widget-scoped; a scoped key can only reach forms bound to that widget (plus org-wide forms).
Authorization: Bearer nextcap_tk_YOUR_KEY# or, equivalently:X-Api-Key: nextcap_tk_YOUR_KEY
Quick Start
With a form whose key is contact-us and two fields (f_topic, a select; f_message, long text):
// In your backend — never from the browser.const res = await fetch("https://api.nextcap.ai/api/v1/external/v1/forms/contact-us/submissions", {method: "POST",headers: {"Authorization": "Bearer " + process.env.NEXTCAP_TICKETS_KEY,"Content-Type": "application/json",},body: JSON.stringify({external_user_id: user?.id, // optional; makes per-person limits realemail: "alice@acme.com",name: "Alice",locale: "en",origin_url: "https://acme.com/contact",answers: [{ question_id: "f_topic", value: "billing" },{ question_id: "f_message", value: "I was charged twice for invoice #4421." },],}),});const body = await res.json();// {// submission_id: "…",// attempt_number: 1,// ticket: { id: "…", number: 1042 },// confirmation: { sent: true, locale: "en" }// }
Identity & Per-Person Limits
A form can cap how many times one person may submit (Submissions per person in the dashboard; max_submissions in the API). To enforce that we need to know who is submitting. Resolution order, strongest first:
external_user_idstringYour own logged-in user id. A real lock — the person cannot swap it. Send it whenever the submitter is authenticated on your side.
emailstringFalls back to the request-level email, then the answer to the form's primary-email field. Stops honest double-submits; anyone can use another address.
(none)—Anonymous. Only accepted on unlimited forms; a capped form with no identity is rejected with 400 identity_required.
The limit is enforced by a unique index, not a read-then-write, so two tabs racing on the last allowed attempt cannot both get through. Attempts are numbered per person; nothing is ever deleted — an unlock voids the current attempt so the person can submit again, and the voided row stays in the record with who authorised it and why.
Confirmation Emails & the Receipt Flag
If the form has a primary email field (or you pass email) and the ticket auto-reply is configured, the submitter gets a confirmation. Only fields you explicitly mark Echo in confirmation email in the dashboard are included in it — every other answer is omitted from the email on purpose, so an ID number or account number is never reflected back into an inbox by accident. Agents still see every answer on the ticket. The response tells you whether the receipt went out and, if not, why (confirmation.sent / confirmation.reason).
Endpoints
Base URL https://api.nextcap.ai/api/v1. :form_key is the URL-safe key shown on the form in the dashboard.
Errors
Errors are { message, details } with a machine-readable message so you can surface the right field to the end user without parsing prose.
400 missing_required_answerdetails.question_id, labelA required field was empty.
400 invalid_answer_valuedetails.question_id, label, reasonWrong type or shape. reason ∈ expected_string, too_long, too_short, pattern_mismatch, not_a_number, below_min, above_max, invalid_email, invalid_url, not_in_options, expected_array, non_string_value, invalid_date, expected_boolean.
400 unknown_question_iddetails.question_idAn answer referenced a field not on this form (stale id, or wrong form).
400 identity_requireddetails.form_key, max_submissionsThe form limits submissions per person but no identity was supplied.
400 insufficient_identity / identity_mismatchdetailsUnlock: fewer than two identifiers, or they don't match the stored row.
400 (form not active)—The form is draft or archived. Activate it in the dashboard.
403—Browser-originated request, plan without the Tickets API, or a widget-scoped key reaching another widget's form.
404—No such form_key for this organization.
409 submission_limit_reacheddetails.attempts_used, max_submissions, submission_id, submitted_atThis person is at their limit. details.submission_id is their existing entry — link it in your CRM, offer the unlock path from your back office, or tell them they've already submitted.
409 submission_conflictdetails.reasonRare: concurrent submissions kept colliding on the attempt number. Retry once.
Where Submissions Show Up
- Tickets inbox — each submission is a ticket (source
api) with the answers rendered on the timeline. Data-only forms still create the row, just without SLA or notifications. - Tickets → Forms → (form) → Submissions — every attempt per person, active and voided, with a link to the ticket and an operator unlock.
- Webhooks —
ticket.createdfires as for any ticket; the ticket'smetadatacarriesformId,formKey,formSubmissionIdandformAttemptNumber.