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",
"department_id": "uuid",
"agent_id": "uuid",
"created_at": "2026-04-27T12:00:00.000Z"
}
}
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...", ... },
{ "id": "...", "ticket_number": 138, "source": "widget", "status": "resolved", "subject": "How do I export...", ... }
],
"next_cursor": "2026-04-20T08:11:42.000Z"
}
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",
"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. Returned url is a 24-hour presigned S3-style link.

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" }
}'
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)json
{
"data": [
{
"id": "dept_billing", "name": "Billing", "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", "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", "parent_id": "dept_billing",
"questions": [],
"children": []
}
]
},
{
"id": "dept_tech", "name": "Technical", "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.

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.