Next CapNext Cap

Tickets API

Let end users file support tickets directly from your own product. Server-to-server, enterprise-only, unifies with widget chats.

Overview

The Tickets API lets your end users file support tickets directly from inside your own product — your billing dashboard, your settings page, your mobile app — without going through the embedded chat widget. Tickets created this way show up in the same Next Cap inbox as widget tickets, fire the same webhooks, and respect the same departments, SLAs, and routing rules.

Enterprise plan only. The Tickets API is sold as a discrete enterprise feature. Tickets created through this path are not counted against your monthly ticket cap.
Update — May 2026: Reply attachments. If your integration uploads files via POST /external/v1/tickets/:id/attachments and expects them to appear next to a customer message, you now need to follow up with POST /external/v1/tickets/:id/replies and pass the new attachment_ids: […] field. Files uploaded without that follow-up still belong to the ticket but won't sit next to a reply in the agent dashboard. Equivalently, on the read side, agent attachments are exposed under events[].attachments in the GET ticket response — make sure your customer-facing UI reads and renders that array. See the Endpoints section below for the full two-step example.

Widget only

Visitors file from the chat widget. Default behavior — no integration work needed.

API only

Tickets are created from your own product's UI via your backend. The chat widget can be hidden or absent.

Both

Either path works. Tickets from the same end user merge into one history regardless of where they were filed.

How It Works

Server-to-server only. Your backend holds an nextcap_tk_ key and calls our REST API on behalf of your authenticated end user. Requests sent directly from a browser are rejected — keep the key on the server.

  • Each request identifies the end user with a stable external_user_id (your internal user id) plus their email.
  • Tickets created here are tagged with that identifier so when the same user later files via the chat widget (or vice versa), both tickets appear in their unified history.
  • An optional HMAC identity hash lets you prove that the body really came from your backend on behalf of that user — useful as a defence-in-depth when end users can also reach the API via your own backend proxy.
  • Per-widget toggles control which surfaces accept ticket creation: chat widget, this API, or both.

Authentication

Tickets API keys use the branded nextcap_tk_ prefix and are org-level — not tied to a dashboard user, so revoking a teammate doesn't kill your integration.

HTTP Headerbash
Authorization: Bearer nextcap_tk_YOUR_KEY
# or, equivalently:
X-Api-Key: nextcap_tk_YOUR_KEY
Server-to-server only. Any request that arrives with an Origin header is rejected with 403. Never embed this key in a browser bundle, mobile app, or anywhere a visitor can read it.

Generating a Key

Tickets API keys are minted from your Next Cap dashboard — there is no public endpoint to mint one. Sign in as an Owner or Admin and follow the steps below.

  1. Open Settings → API Keys in the dashboard.
  2. Switch to the Tickets API tab.
  3. Click Create key, give it a descriptive name (e.g. “Production backend”), and submit.
  4. The dashboard reveals two values exactly once. Copy them immediately.
  5. Store them as environment variables on your backend, e.g. NEXTCAP_TICKETS_KEY and NEXTCAP_TICKETS_SECRET.

The two values you receive:

rawKey

nextcap_tk_… — pass as the bearer token on every request.

rawSecret

64-char hex string — used to sign optional HMAC identity hashes. Stored encrypted on our side; you cannot retrieve it later.

Both rawKey and rawSecret are returned exactly once. If you lose them, revoke the key from the dashboard and create a new one. To rotate, create the new key first, deploy it, then revoke the old one.

Per-Widget Enable

Each widget has two independent toggles for ticket creation: via Widget and via API. Both default to off for the API path. Enable per widget in the agent builder Features panel — the toggle is gated to the Enterprise plan. The API rejects ticket creation for any agent_id with API creation disabled (403 api_creation_disabled_for_widget).

Identity Verification (Optional)

By default the API key alone is the authority — anyone holding it can act on behalf of any end user. If you want stricter trust (e.g. you proxy requests on behalf of authenticated users and want a defence-in-depth that the body wasn't tampered with), include an identity_hash field on end_user:

Hash formulatext
identity_hash = HMAC_SHA256(rawSecret, "${external_user_id}:${email}").hex()
import { createHmac } from "crypto";
// Compute on YOUR backend, send to our API.
function signEndUser(externalUserId, email, secret) {
return createHmac("sha256", secret)
.update(`${externalUserId}:${email}`)
.digest("hex");
}
const identity_hash = signEndUser(
user.id,
user.email,
process.env.NEXTCAP_TICKETS_SECRET,
);
// Pass alongside the rest of end_user
body.end_user.identity_hash = identity_hash;

When present and valid, the request is treated as identity-verified. When present and invalid, the request is rejected with a 403. Omitting the field is fine — the API key still authenticates the call.

Quick Start

// In your backend (Node / Next.js API route / Express handler)
const res = await fetch("https://api.nextcap.ai/api/v1/external/v1/tickets", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.NEXTCAP_TICKETS_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
agent_id: "WIDGET_UUID",
subject: "Charged twice for invoice #4421",
description: "I see two pending charges on my card for the same invoice.",
priority: "high",
end_user: {
external_user_id: req.session.userId, // YOUR stable user id
email: req.session.userEmail,
name: req.session.userName,
},
metadata: {
page_url: req.headers.referer,
app_version: "2025.04",
},
}),
});
const { data } = await res.json();
console.log("Ticket created:", data.ticket_number);

Intake Questions

Each department can carry a list of intake questions that the end user must answer when they file a ticket — account number, error code, screenshot link, etc. Configure them in the dashboard under Tickets → Departments → (pick a widget) → (pick a department) → Intake questions. Each question has a type (short_text, long_text, number, email, url, select, multi_select, date, checkbox), an optional helper URL, and a required flag.

  • Tree inheritance. Questions can attach to any node in the department tree, not only leaves. The effective set for a picked department is the union of every question on it and every ancestor — root first, leaf last.
  • Snapshot on submit. When you POST a ticket with intake_answers, we store a snapshot on the ticket: the question id, label, type, and value. Later edits or deletions of the source question never rewrite the answer, so historical tickets remain readable.
  • Validation. Required questions block submission. Each value is checked per type — email regex, URL shape, select-option membership, etc. Failures return 400 with a structured details payload pointing at the offending question.

Workflow your portal should follow:

  1. Call GET /external/v1/departments?agent_id=…&tree=true. Each node carries a questions[] array of the questions defined directly on it.
  2. Render the tree as a drill-down picker. Once the user reaches a node they want to file under, accumulate its questions plus every ancestor's.
  3. Render the appropriate input for each question. Submit with intake_answers: [{ question_id, value }] on POST /external/v1/tickets.
POST /external/v1/tickets — request bodyjson
{
"agent_id": "uuid",
"subject": "Refund for invoice #4421",
"description": "Charged twice for the same invoice.",
"department_id": "dept_billing_refunds_uuid",
"end_user": {
"external_user_id": "u_42",
"email": "alice@acme.com"
},
"intake_answers": [
{ "question_id": "q_account_number", "value": "ACME-9981" },
{ "question_id": "q_invoice_id", "value": "INV-4421" },
{ "question_id": "q_priority", "value": "high" },
{ "question_id": "q_screenshot_url", "value": "https://files.acme.com/abc123.png" }
]
}
GET /external/v1/tickets/:id — response (excerpt)json
{
"data": {
"id": "...",
"subject": "Refund for invoice #4421",
"department_id": "dept_billing_refunds_uuid",
"intake_answers": [
{
"question_id": "q_account_number",
"label": "Account number",
"type": "short_text",
"value": "ACME-9981",
"required": true
},
{
"question_id": "q_invoice_id",
"label": "Invoice ID",
"type": "short_text",
"value": "INV-4421",
"required": true
},
{
"question_id": "q_priority",
"label": "How urgent is this?",
"type": "select",
"value": "high",
"required": false,
"helper_url": "https://help.acme.com/sla"
},
{
"question_id": "q_screenshot_url",
"label": "Screenshot URL",
"type": "url",
"value": "https://files.acme.com/abc123.png",
"required": false
}
]
}
}

Endpoints

POST/external/v1/ticketsTickets Key

Create a ticket on behalf of an end user.

agent_idUUIDrequired

The widget this ticket belongs to. Must have the Tickets API enabled in its Features panel.

subjectstringrequired

Short title for the ticket. Required, must be non-empty.

descriptionstring

Long-form body of the ticket. Optional but recommended.

department_idUUID

Pin to a specific department. Must be visible to the widget; otherwise routing falls back to language-aware auto-pick or the widget default.

priorityenum

"low" | "normal" | "high" | "urgent". Defaults to "normal".

categorystring

Free-form label. Used for filtering in the dashboard.

tagsstring[]

Flexible tags for filtering and organization.

custom_fieldsobject

Org-defined custom fields (key-value pairs configured per org).

metadataobject

Free-form context (page URL, app version, etc.). Stored on the ticket and returned on every read. Use it to tag tickets with their originating panel/product when one API key serves multiple internal apps — e.g. {"panel_source": "b2"}. Max 10KB.

intake_answersarray

Visitor's answers to the department's intake questions: [{ question_id, value }]. Validated against the effective set for the chosen department; required ones must be present, types must match. See the Intake Questions section above for the full flow.

end_user.external_user_idstringrequired

Your stable internal id for this user. Powers cross-channel unification.

end_user.emailstringrequired

The user's email address. Used for visitor matching and notification routing.

end_user.namestring

Display name.

end_user.identity_hashhex string

Optional HMAC-SHA256 of `${external_user_id}:${email}` keyed by your rawSecret. See Identity Verification.

Successful responsejson
{
"data": {
"id": "uuid",
"ticket_number": 142,
"subject": "Charged twice for invoice #4421",
"status": "new",
"priority": "high",
"source": "api",
"external_user_id": "u_42",
"visitor_email": "alice@acme.com",
"agent_id": "uuid",
"agent_name": "Otet Bot .IR",
"agent_display_name": "Otet Support",
"department_id": "uuid",
"department": {
"id": "uuid",
"name": "Technical Support",
"name_translations": { "fa": "پشتیبانی فنی", "fr": "Support technique" },
"description_translations": { "fa": "...", "fr": "..." },
"supported_languages": ["en", "fa", "fr"],
"color": "#3B82F6",
"is_default": false
},
"created_at": "2026-04-27T12:00:00.000Z"
}
}

Localising the routing labels: read department.name_translations[locale] and fall back to department.name. The map only contains locales you have translated in the dashboard. department is null when the ticket has no department. agent_name is the widget's internal name; agent_display_name is the user-facing label (use it if set, otherwise fall back to agent_name).

GET/external/v1/tickets?external_user_id=…Tickets Key

List the end user's tickets across all surfaces (API + widget).

external_user_idstringrequired

Filter to tickets for this end user.

emailstring

Optional. When provided, also returns earlier widget tickets the user filed before you started passing an external_user_id — useful for showing a complete history on first integration.

statusstring

Filter by status (e.g. open, closed, resolved).

limitnumber

Page size, default 50, max 200.

cursorISO timestamp

Pagination cursor — pass back the next_cursor returned by the previous page.

identity_hashhex string

Optional. When email is also provided, a valid HMAC verifies the caller is asserting the right identity.

Successful responsejson
{
"data": [
{
"id": "...", "ticket_number": 142, "source": "api", "status": "open",
"subject": "Charged twice...",
"agent_id": "uuid", "agent_name": "Otet Bot .IR", "agent_display_name": "Otet Support",
"department_id": "uuid",
"department": {
"id": "uuid", "name": "Billing",
"name_translations": { "fa": "صورتحساب" },
"description_translations": {},
"supported_languages": ["en", "fa"],
"color": "#3B82F6", "is_default": false
},
...
},
{ "id": "...", "ticket_number": 138, "source": "widget", "status": "resolved", "subject": "How do I export...", "agent_name": "Otet Bot .IR", "department": { ... }, ... }
],
"next_cursor": "2026-04-20T08:11:42.000Z"
}

Each item carries the same fields as the create response — including agent_name, agent_display_name, and the nested department with locale-specific translations. Render the routing labels in the user's language by reading department.name_translations[locale] and falling back to department.name.

GET/external/v1/tickets/:id?external_user_id=…&email=…Tickets Key

Fetch one ticket plus its public timeline (internal notes are never returned).

The end user must own the ticket — either by external_user_id match or, for legacy tickets, by email match. Otherwise we return 404 (not 403) so we don't leak the existence of other users' tickets at this id.

Each event under events[] carries an attachments array — files the agent (or the end user) attached to that specific reply. URLs are 24-hour presigned and ready to render as <img> / download links without further auth. initial_attachments is a sibling array for files the user attached when the ticket was first created.

Successful response (excerpt)json
{
"data": {
"id": "ticket_uuid",
"ticket_number": 142,
"subject": "Charged twice for invoice #4421",
"status": "open",
"agent_id": "uuid",
"agent_name": "Otet Bot .IR",
"agent_display_name": "Otet Support",
"department_id": "uuid",
"department": {
"id": "uuid",
"name": "Billing",
"name_translations": { "fa": "صورتحساب", "fr": "Facturation" },
"description_translations": {},
"supported_languages": ["en", "fa", "fr"],
"color": "#3B82F6",
"is_default": false
},
"events": [
{
"id": "evt_uuid_1",
"type": "agent_reply",
"actor_type": "agent",
"actor": { "id": "user_uuid", "name": "Sara from Acme" },
"content": "Refund issued — confirmation attached.",
"is_internal": false,
"attachments": [
{
"id": "att_uuid",
"filename": "refund-confirmation.pdf",
"content_type": "application/pdf",
"size": 84213,
"url": "https://r2.nextcap.ai/...?signature=..."
}
],
"created_at": "2026-04-27T13:42:11.000Z"
},
{
"id": "evt_uuid_2",
"type": "customer_reply",
"actor_type": "visitor",
"actor": null,
"content": "Thanks!",
"is_internal": false,
"attachments": [],
"created_at": "2026-04-27T13:45:02.000Z"
}
],
"initial_attachments": [
{
"id": "att_uuid_initial",
"filename": "duplicate-charge.png",
"content_type": "image/png",
"size": 320918,
"url": "https://r2.nextcap.ai/...?signature=..."
}
]
}
}
POST/external/v1/tickets/:id/repliesTickets Key

Add an end-user reply to a ticket. Fires the ticket.customer_reply webhook.

contentstring

The reply body. Markdown is preserved as-is. Optional only when attachment_ids carries at least one file — at least one of the two must be present.

attachment_idsUUID[]

IDs returned by POST /external/v1/tickets/:id/attachments. Linking attachments here is what makes them appear next to the customer's reply in the agent dashboard timeline; uploads without this link become orphans and are easy for agents to miss.

end_user.external_user_idstringrequired

Same end-user identity as on create.

end_user.emailstringrequired

end_user.namestring

end_user.identity_hashhex string

Optional HMAC, same shape as on create.

Reply with attachments — request bodyjson
{
"content": "Here's the screenshot you asked for.",
"attachment_ids": ["att_uuid_1", "att_uuid_2"],
"end_user": {
"external_user_id": "u_42",
"email": "alice@acme.com"
}
}
POST/external/v1/tickets/:id/attachmentsTickets Key

Upload a file attachment to a ticket. Multipart form upload.

Send as multipart/form-data with the file under the file field, plus external_user_id and optional email in the form body. The transport ceiling is 200 MB; per-plan limit (default 50 MB) is re-checked after auth. Only common attachment types are accepted (images, PDF, text, Office docs, common audio/video) — HTML, SVG, ZIP archives, and executables are rejected with 400. Returned url is a 24-hour presigned S3-style link that carries Content-Disposition: attachment, so opening it in a browser triggers a download rather than inline rendering. To embed the file in your own UI, fetch it server-side and re-serve from your origin.

Two-step flow: upload here to get the file id, then POST to /replies with that id in attachment_ids. Files uploaded without a follow-up reply are orphans — they still belong to the ticket but won't sit next to a customer message in the agent timeline.

Step 1 — uploadbash
curl -X POST https://api.nextcap.ai/api/v1/external/v1/tickets/$TICKET_ID/attachments \
-H "Authorization: Bearer $NEXTCAP_TICKETS_KEY" \
-F "file=@./screenshot.png" \
-F "external_user_id=u_42" \
-F "email=alice@acme.com"
# → { "data": { "id": "att_uuid", "filename": "screenshot.png", "url": "...", ... } }
Step 2 — link to a replybash
curl -X POST https://api.nextcap.ai/api/v1/external/v1/tickets/$TICKET_ID/replies \
-H "Authorization: Bearer $NEXTCAP_TICKETS_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "Screenshot attached.",
"attachment_ids": ["att_uuid"],
"end_user": { "external_user_id": "u_42", "email": "alice@acme.com" }
}'
POST/external/v1/tickets/:id/attachments/presignTickets Key

Step 1 of the presigned-URL upload flow. Returns a one-shot R2 PUT URL plus an upload_id you'll pass to /complete after the bytes have been uploaded.

Use this when you don't want to push file bytes through your own backend in a multipart request. The flow is identical to CRM Attachments: presign → PUT → complete. The upload_id returned here is scoped to this ticket and the supplied end_user — both are re-checked on /complete. content_type must be on the same allowlist the multipart endpoint uses — HTML, SVG, ZIP archives, and executables are rejected with 400.

filenamestringrequired

Original filename. Will be sanitised before the object is keyed in R2.

content_typestringrequired

MIME type. Must match the Content-Type header you send on the PUT exactly, otherwise R2 will reject the upload.

sizeintegerrequired

Declared file size in bytes. Re-validated against the actual size at /complete; oversized uploads are rejected and the staged object is deleted.

end_user.external_user_idstringrequired

Same end-user identity used everywhere else on this API.

end_user.emailstringrequired

end_user.identity_hashhex string

Optional HMAC, same shape as on create / reply.

Request a presigned URLbash
curl -X POST https://api.nextcap.ai/api/v1/external/v1/tickets/$TICKET_ID/attachments/presign \
-H "Authorization: Bearer $NEXTCAP_TICKETS_KEY" \
-H "Content-Type: application/json" \
-d '{
"filename": "screenshot.png",
"content_type": "image/png",
"size": 320918,
"end_user": { "external_user_id": "u_42", "email": "alice@acme.com" }
}'
Responsejson
{
"data": {
"upload_id": "upload_uuid",
"upload_url": "https://<account>.r2.cloudflarestorage.com/...?X-Amz-Signature=...",
"expires_at": "2026-05-11T13:42:11.000Z",
"max_size_bytes": 52428800
}
}
Step 2 — upload the bytes (no auth header, presigned URL carries it)bash
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/png" \
--data-binary @./screenshot.png
POST/external/v1/tickets/:id/attachments/:upload_id/completeTickets Key

Step 3 of the presigned-URL flow. Verifies the file landed in storage, registers it on the ticket, and returns the same shape /attachments returns — pass id to /replies in attachment_ids[].

Call this after the PUT succeeds. The server HEADs the object to confirm the upload completed and re-checks the actual size against your plan's limit (default 50 MB). Oversized files are rejected and the staged object is deleted. The returned id is the real attachment id — use it as attachment_ids[] on /replies exactly like the multipart endpoint above. The returned url is a 24-hour presigned link with Content-Disposition: attachment — opening it in a browser triggers a download. Fetch and re-serve from your own origin if you need inline rendering in your UI.

end_user.external_user_idstringrequired

Same end-user identity used at presign time.

end_user.emailstringrequired

end_user.identity_hashhex string

Optional HMAC, same shape as on create / reply.

Step 3 — register the upload on the ticketbash
curl -X POST https://api.nextcap.ai/api/v1/external/v1/tickets/$TICKET_ID/attachments/$UPLOAD_ID/complete \
-H "Authorization: Bearer $NEXTCAP_TICKETS_KEY" \
-H "Content-Type: application/json" \
-d '{
"end_user": { "external_user_id": "u_42", "email": "alice@acme.com" }
}'
# → { "data": { "id": "att_uuid", "filename": "screenshot.png", "content_type": "image/png", "size": 320918, "url": "..." } }
Step 4 — link the attachment to a reply (same as multipart)bash
curl -X POST https://api.nextcap.ai/api/v1/external/v1/tickets/$TICKET_ID/replies \
-H "Authorization: Bearer $NEXTCAP_TICKETS_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "Screenshot attached.",
"attachment_ids": ["att_uuid"],
"end_user": { "external_user_id": "u_42", "email": "alice@acme.com" }
}'
GET/external/v1/departments?agent_id=…&tree=trueTickets Key

Fetch the department picker the end user should see when filing a ticket. Each node carries its own intake questions; clients accumulate the effective set by walking ancestors.

agent_idUUID

Scope to a specific widget. Returns org-wide departments + departments explicitly assigned to that widget.

tree"true" | "false"

When true, departments are returned nested by parent_id (each node has a children[] array). When false (default), a flat list is returned and parent_id tells you the relationships.

Tree response (with intake questions + translations)json
{
"data": [
{
"id": "dept_billing",
"name": "Billing",
"name_translations": { "fa": "صورتحساب", "fr": "Facturation" },
"description_translations": { "fa": "...", "fr": "..." },
"supported_languages": ["en", "fa", "fr"],
"color": "#3B82F6",
"parent_id": null,
"is_default": true,
"questions": [
{
"id": "q_account_number",
"type": "short_text",
"label": "Account number",
"description": null,
"helper_url": null,
"placeholder": "ACME-12345",
"required": true,
"position": 0,
"options": []
}
],
"children": [
{
"id": "dept_refunds",
"name": "Refunds",
"name_translations": { "fa": "استرداد وجه", "fr": "Remboursements" },
"parent_id": "dept_billing",
"questions": [
{ "id": "q_invoice_id", "type": "short_text", "label": "Invoice ID", "required": true, "options": [], "position": 0 },
{ "id": "q_screenshot_url", "type": "url", "label": "Screenshot URL", "required": false, "options": [], "position": 1 }
],
"children": []
},
{
"id": "dept_disputes",
"name": "Disputes",
"name_translations": { "fa": "اعتراضات", "fr": "Litiges" },
"parent_id": "dept_billing",
"questions": [],
"children": []
}
]
},
{
"id": "dept_tech",
"name": "Technical",
"name_translations": { "fa": "پشتیبانی فنی", "fr": "Support technique" },
"parent_id": null,
"questions": [],
"children": [ ... ]
}
]
}

The questions[] on each node are the questions defined directly on that department. To compute the full set the visitor must answer when filing under a given node, walk from that node up to its root and concatenate every ancestor's questions in order — see the Intake Questions section above.

Localising the department picker: read name_translations[locale] / description_translations[locale] and fall back to the top-level name / description when the user's locale isn't in the map. Translation maps are flat, keyed by ISO 639-1 ("fa", "fr", …); only locales translated in the dashboard appear. supported_languages is an empty array when the department accepts all languages, otherwise the allowed list.

Webhook Events

Tickets created via this API fire the same webhooks as widget-created tickets — your existing CRM integration picks them up automatically. Inspect data.source === "api" and data.external_user_id if you need to branch on origin.

  • ticket.created — fires when a ticket is filed via this API.
  • ticket.customer_reply — fires on POST /tickets/:id/replies. Payload now carries attachment_ids: string[] when the reply linked any uploads (omitted when empty).
  • All other ticket events (ticket.assigned, ticket.status_changed, etc.) fire normally when your team acts on the ticket from the dashboard.
ticket.customer_reply payload (with attachments)json
{
"event": "ticket.customer_reply",
"data": {
"ticket_id": "uuid",
"content": "Screenshot attached.",
"attachment_ids": ["att_uuid_1", "att_uuid_2"],
"external_user_id": "u_42",
"timestamp": "2026-05-04T13:42:11.000Z"
}
}

See Webhooks for signing, retries, and the full payload shape.

Cross-Channel Unification

When the same end user files a ticket via the API and chats with the widget, both interactions appear under one history. The match rules are simple:

  1. Same external user id wins. If your widget calls nextcap.identify({ userId, userHash }) with the same id you pass to the API, the two surfaces share one history immediately.
  2. Email link. If the widget visitor previously gave an email that matches your API call's end_user.email, the prior chats become linked to that user the first time you submit via the API.
  3. New contact. If neither matches, the user is treated as a brand-new contact in your organization.
For the tightest unification, also enable widget identity verification and pass the same userId to both nextcap.identify() and the API's external_user_id. Email-only matching covers the common case but can't link genuinely anonymous visitors.

Rate Limits

Tickets API keys default to 600 requests per minute, generous enough that flooding protection kicks in well before you notice. Per-key limits are configurable on request. Standard X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers are returned — see Errors & Rate Limits.

Common Errors

  • 401 — the API key is invalid, revoked, or expired. Generate a new nextcap_tk_ key from the dashboard.
  • 403 — the request was sent with a browser Origin header. Move the call to your backend.
  • 403 — your plan does not include the Tickets API. Upgrade to Enterprise or contact sales.
  • 403 api_creation_disabled_for_widget — the agent_id you sent has the Tickets API toggle off. Enable it in the widget's Features panel.
  • 403 — the identity_hash didn't verify against your signing secret. Re-check the input format ($${external_user_id}:${email}) and that you used the right secret.
  • 400 — the department_id isn't one this widget can route to. Pick a department visible to the widget or omit the field.
  • 400 missing_required_answer — one of the department's required intake questions wasn't in intake_answers. The response details.question_id tells you which one.
  • 400 invalid_answer_value — an answer's value didn't pass type validation (bad email, value not in select options, number out of range, etc.). The details payload includes the question_id and a reason code.
  • 400 unknown_question_idintake_answers contained an id that isn't in the chosen department's effective question set. Refetch /external/v1/departments?tree=true to pick up renames or deletions.
  • 404 — ticket not found. Either the id is wrong or it doesn't belong to the end user you provided.
  • 429 — you exceeded the rate limit. Back off using the Retry-After header.
Need real-time push of ticket events into your own product? Combine this API with Webhooks — fire updates back into the customer portal the moment your support team responds.