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.
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.
Authorization: Bearer nextcap_tk_YOUR_KEY# or, equivalently:X-Api-Key: nextcap_tk_YOUR_KEY
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.
- Open Settings → API Keys in the dashboard.
- Switch to the Tickets API tab.
- Click Create key, give it a descriptive name (e.g. “Production backend”), and submit.
- The dashboard reveals two values exactly once. Copy them immediately.
- Store them as environment variables on your backend, e.g.
NEXTCAP_TICKETS_KEYandNEXTCAP_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.
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:
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_userbody.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 idemail: 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
400with a structureddetailspayload pointing at the offending question.
Workflow your portal should follow:
- Call
GET /external/v1/departments?agent_id=…&tree=true. Each node carries aquestions[]array of the questions defined directly on it. - 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.
- Render the appropriate input for each question. Submit with
intake_answers: [{ question_id, value }]onPOST /external/v1/tickets.
{"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" }]}
{"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
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 onPOST /tickets/:id/replies. Payload now carriesattachment_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.
{"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:
- 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. - 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. - New contact. If neither matches, the user is treated as a brand-new contact in your organization.
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 newnextcap_tk_key from the dashboard.403— the request was sent with a browserOriginheader. 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— theagent_idyou sent has the Tickets API toggle off. Enable it in the widget's Features panel.403— theidentity_hashdidn't verify against your signing secret. Re-check the input format ($${external_user_id}:${email}) and that you used the right secret.400— thedepartment_idisn'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 inintake_answers. The responsedetails.question_idtells you which one.400 invalid_answer_value— an answer's value didn't pass type validation (bad email, value not inselectoptions, number out of range, etc.). Thedetailspayload includes thequestion_idand areasoncode.400 unknown_question_id—intake_answerscontained an id that isn't in the chosen department's effective question set. Refetch/external/v1/departments?tree=trueto 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 theRetry-Afterheader.