Next CapNext Cap

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.

Enterprise plan only. The Forms API is authenticated with your existing Tickets API key — no new key type, no new scope. If you already integrate the Tickets API you are already set up.

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

  1. 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.
  2. Your backend calls GET /external/v1/forms/:form_key to 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.
  3. Your site posts to your backend, which relays to POST /external/v1/forms/:form_key/submissions with the answers keyed by field id.
  4. 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.
Server-to-server only. The 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).

HTTP Headerbash
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 real
email: "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_idstring

Your own logged-in user id. A real lock — the person cannot swap it. Send it whenever the submitter is authenticated on your side.

emailstring

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

GET/external/v1/forms/:form_keyTickets Key

Fetch the form definition — name, description, status and the ordered field list — so you can render the form from our source of truth.

Query
localestring

Return name/description/field labels in this language when a translation exists (en, es, fr, tr, ar, fa). Falls back to the source text.

const res = await fetch("https://api.nextcap.ai/api/v1/external/v1/forms/contact-us?locale=fr", {
headers: { "Authorization": "Bearer " + process.env.NEXTCAP_TICKETS_KEY },
});
const form = await res.json();
// form.fields → render one input per field, keyed by field.id
Response — 200 OKjson
{
"form_key": "contact-us",
"name": "Contact us",
"description": null,
"status": "active",
"default_locale": "en",
"max_submissions": null,
"fields": [
{
"id": "f_topic",
"type": "select",
"label": "What is this about?",
"description": null,
"placeholder": null,
"helper_url": null,
"required": true,
"position": 0,
"options": [
{ "value": "billing", "label": "Billing" },
{ "value": "bug", "label": "Something is broken" }
],
"validation": {}
},
{
"id": "f_message",
"type": "long_text",
"label": "Tell us more",
"required": true,
"position": 1,
"options": [],
"validation": { "maxLength": 4000 }
}
]
}

Field type is one of short_text, long_text, number, email, url, select, multi_select, date, checkbox. validation may carry minLength, maxLength, pattern, min, max. Whether a field is echoed in the receipt is intentionally not exposed.

GET/external/v1/forms/:form_key/submission-statusTickets Key

Pre-flight: is this person already at their limit? Call it before rendering the form so a locked submitter doesn't fill everything in and only find out on submit.

Query — send at least one
external_user_idstring

Your user id.

emailstring

Submitter email. Ignored when external_user_id is present.

Response — 200 OKjson
{
"locked": false,
"attempts_used": 0,
"max_submissions": 1,
"last_submission_id": null,
"last_submitted_at": null
}
POST/external/v1/forms/:form_key/submissionsTickets Key

Submit the form. Validates answers, enforces the per-person limit, creates the ticket, snapshots the answers, sends the receipt.

Body
answersarray

[{ question_id, value }] — question_id is a field id from the definition. Required fields must be present; values are validated per type. Unknown ids are rejected (400 unknown_question_id) rather than dropped.

external_user_idstring

Your logged-in user id. Optional, but the only identity strong enough to make a per-person limit real.

emailstring

Overrides the primary-email field. Sets the ticket's contact email; required for a receipt.

namestring

Overrides the primary-name field.

localestring

Receipt language (en, es, fr, tr, ar, fa). Defaults to the form's default locale.

origin_urlstring

Page the submitter was on. Full URL with protocol.

metadataobject

Anything you want stored on the submission and the ticket.

attachment_idsstring[]

Reserved. Attachment staging is not yet wired for forms — sending ids returns 400 attachments_not_supported_yet. File attachments via the Tickets API meanwhile.

Response — 201 Createdjson
{
"submission_id": "5c1a…",
"attempt_number": 1,
"ticket": { "id": "9b2f…", "number": 1042 },
"confirmation": { "sent": true, "locale": "en" }
}

When the receipt was not sent, confirmation.sent is false and confirmation.reason is no_email (no address to send to) or no_rule_or_suppressed (no matching auto-reply rule, recipient blocklisted, or cooldown). The ticket subject is derived server-side (<form name> — <name> (<email>)) unless the form sets a subject template.

POST/external/v1/forms/:form_key/unlockTickets Key

Grant a specific person another attempt on a capped form. Voids their current active attempt(s); nothing is deleted.

Body — at least TWO of the identifiers, and they must agree with the stored row
external_user_idstring

Your user id.

emailstring

Submitter email.

submission_idstring

A specific submission id from an earlier response.

reasonstring

Why the extra attempt was granted. Stored on the voided row and shown in the dashboard.

Response — 200 OKjson
{
"unlocked": true,
"form_key": "contact-us",
"voided_submission_ids": ["5c1a…"],
"attempts_used": 0,
"max_submissions": 1
}

Two identifiers are required because unlocking hands out another entry — on a one-per-person campaign, an email-only unlock would be guessable.

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, label

A required field was empty.

400 invalid_answer_valuedetails.question_id, label, reason

Wrong 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_id

An answer referenced a field not on this form (stale id, or wrong form).

400 identity_requireddetails.form_key, max_submissions

The form limits submissions per person but no identity was supplied.

400 insufficient_identity / identity_mismatchdetails

Unlock: 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_at

This 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.reason

Rare: 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.
  • Webhooksticket.created fires as for any ticket; the ticket's metadata carries formId, formKey, formSubmissionId and formAttemptNumber.