Server-to-server endpoint for creating Punchline tickets from external systems (Skinlyzer feedback widget, Sentry, Posthog, etc.).
X-Punchline-Key header.A Punchline project admin issues you a key from project settings. The plaintext is shown exactly once at creation; store it as a secret.
X-Punchline-Key: pnchl_live_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuVwXy
If a key is leaked or no longer needed, the admin revokes it from the same settings page. Revocation takes effect immediately.
Key types: the
X-Punchline-Keyheader also accepts a user-level key (pnchl_user_..., created on a user's own Settings page). User keys are provisioning-only — they work exclusively onPOST /api/v1/external/projectsand return 403 everywhere else, including every endpoint on this page. Use a project key (pnchl_live_...) here.
Each key carries scopes that bound what it can do:
| Scope | Grants |
|---|---|
read:own | List/get tickets this key created (default). |
read:all | List/get every ticket in the project. |
write:own | Create tickets; patch/comment on tickets this key created (default). |
write:all | Patch/comment on any ticket in the project. |
New keys default to read:own,write:own. A project admin edits scopes on the
project's Settings → API Keys page. *:all is a superset of the matching
*:own. A request with a valid key that lacks the required scope returns 403.
GET /api/v1/external/me returns the calling key's own metadata — its scopes,
identity, and project. Works for any valid key (no scope required), including a
key with no scopes, so you can self-diagnose a 403.
GET /api/v1/external/me
X-Punchline-Key: <your key>
{
"name": "Skinlyzer feedback widget",
"keyPrefix": "pnchl_live_kza3",
"scopes": ["read:own", "write:own"],
"project": { "slug": "skinlyzer", "name": "Skinlyzer" },
"createdAt": "2026-05-01T10:00:00Z",
"lastUsedAt": "2026-06-30T08:12:00Z"
}
lastUsedAt reflects the last completed request and may be null for a
brand-new key.
Authenticated responses carry X-RateLimit-Limit and X-RateLimit-Remaining
(requests left in the current minute). A 429 Too Many Requests additionally
carries Retry-After: 60. Limits are per API key, per minute, per server
instance.
POST /api/v1/external/tickets
Content-Type: application/json
X-Punchline-Key: <your key>
| Field | Type | Required | Default |
|---|---|---|---|
title | string (1–500 chars) | yes | — |
description | string | no | "" |
reporterEmail | yes | — | |
reporterName | string | no | (email) |
type | BUG | FEATURE | TASK | IMPROVEMENT | IDEA | no | BUG |
priority | CRITICAL | HIGH | MEDIUM | LOW | no | MEDIUM |
pageUrl | string | no | — |
userAgent | string | no | — |
appVersion | string | no | — |
labels | array of strings (≤ 100 items, each ≤ 100 chars) | no | — |
idempotencyKey | string (≤ 255 chars) | no | — |
pageUrl, userAgent, and appVersion are formatted into a Markdown
"Context" block prepended to the description.
labels is an array of label names (e.g. ["billing", "mobile"]). Labels
that don't exist yet are created automatically with a neutral colour. If the
project has auto-assignment enabled, Punchline uses these labels to route the
ticket to the best-matched AGENT (by tier + skill labels, round-robin). Pass
labels that describe the topic of the ticket; routing runs silently in the
background and does not affect the response.
| Status | Body |
|---|---|
| 201 | TicketDTO of the newly created ticket |
| 200 | TicketDTO of an existing ticket — idempotent replay |
| 400 | RFC 7807 ProblemDetail (validation error) |
| 401 | RFC 7807 ProblemDetail (auth error) |
| 403 | RFC 7807 ProblemDetail — key lacks a write scope |
| 415 | RFC 7807 ProblemDetail (wrong content-type) |
Creating a ticket requires a write scope (write:own or write:all); the
default write:own covers it.
If you include idempotencyKey (recommended: a UUID per submission), Punchline
guarantees that retries with the same key + same API key never create more than
one ticket. The second call returns the original ticket with HTTP 200.
The dedup window is the lifetime of the project — keys are not currently expired. A future cleanup job will prune entries older than 7 days; design for that.
curl -X POST https://punchline.example.com/api/v1/external/tickets \
-H "X-Punchline-Key: pnchl_live_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuVwXy" \
-H "Content-Type: application/json" \
-d '{
"title": "Login button broken on Safari",
"description": "Clicking does nothing.",
"reporterEmail": "alice@example.com",
"reporterName": "Alice Smith",
"type": "BUG",
"priority": "HIGH",
"pageUrl": "https://app.example.com/login",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"appVersion": "2.4.1",
"idempotencyKey": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}'
import { randomUUID } from 'node:crypto';
async function createTicket(report) {
const res = await fetch('https://punchline.example.com/api/v1/external/tickets', {
method: 'POST',
headers: {
'X-Punchline-Key': process.env.PUNCHLINE_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: report.title,
description: report.body,
reporterEmail: report.user.email,
reporterName: report.user.name,
type: 'BUG',
priority: 'MEDIUM',
pageUrl: report.context.url,
userAgent: report.context.userAgent,
appVersion: report.context.version,
idempotencyKey: randomUUID(),
}),
});
if (!res.ok) {
throw new Error(`Punchline ${res.status}: ${await res.text()}`);
}
return res.json();
}
| Status | When | Action |
|---|---|---|
| 401 | Missing, malformed, unknown, or revoked API key | Confirm the key, ask the admin if you need a new one |
| 403 | Valid key, but it lacks the scope the action needs (writing without a write scope, or listing all tickets without read:all) | Ask the admin to widen the key's scopes; check GET /api/v1/external/me to see your current scopes |
| 404 | Key has the scope, but the ticket isn't visible to it (created by a different key, with *:own) | Use a key with *:all, or operate only on tickets this key created |
| 400 | Body validation failed | Inspect the ProblemDetail's detail field |
| 415 | Wrong Content-Type | Set Content-Type: application/json |
| 429 | Per-key (or per-IP, for invalid keys) rate limit exceeded | Honor Retry-After (seconds); watch X-RateLimit-Remaining to back off before you hit the limit |
The 401 response is intentionally identical for all four causes so attackers cannot distinguish "key doesn't exist" from "key was revoked". 403 (lacks scope) is deliberately distinct from 404 (in scope, but the ticket isn't visible to this key) so integrators can tell "needs more permission" from "wrong ticket."
Zero-downtime as long as the new key is live before the old one is revoked.
After creating a ticket, the same API key can read its state. Both read
endpoints need a read scope (403 otherwise). With the default read:own they
are scoped to tickets this key submitted via the create endpoint above —
tickets created by other means (the internal UI, email-intake, or a different
API key) return 404. With read:all the key sees every ticket in the project.
GET /api/v1/external/tickets/{displayId}Returns the public-safe ticket state.
GET /api/v1/external/tickets/KEN-12
X-Punchline-Key: <your key>
Response body:
| Field | Type |
|---|---|
displayId | string (e.g. KEN-12) |
title | string |
status | string |
type | enum (see request body above) |
priority | enum |
dueDate | ISO date or null |
labels | array of label names |
closed | boolean |
publicCommentCount | integer |
reporterEmail | string |
createdAt | ISO instant |
updatedAt | ISO instant |
User identifiers, internal comments, attachment URLs, custom fields, and the activity log are intentionally omitted.
GET /api/v1/external/ticketsFiltered list of tickets — those your key submitted (read:own) or every
ticket in the project (read:all). All filters are optional and AND-composed.
| Param | Type | Notes |
|---|---|---|
reporterEmail | exact match, case-insensitive | |
status | string | exact match |
type | enum | exact match |
createdAfter | ISO instant | inclusive lower bound |
createdBefore | ISO instant | exclusive upper bound |
page | integer | default 0 |
size | integer | default 20, max 100 |
Returns a Spring Page wrapper with content, totalElements,
totalPages, number, size. Each item has the same shape as the
single-ticket response above.
After creating a ticket, the same API key can update its workflow status and/or
its customer-facing public summary. This needs a write scope (403 otherwise);
with the default write:own only tickets this key submitted are accessible,
while write:all can update any ticket in the project.
PATCH /api/v1/external/tickets/{displayId}PATCH /api/v1/external/tickets/KEN-12
X-Punchline-Key: <your key>
Content-Type: application/json
At least one field must be non-null.
| Field | Type | Notes |
|---|---|---|
status | string | Workflow status name (e.g. "In Progress", "Done"). Must be a valid transition from the current status; invalid transitions return 422. Omit or pass null to leave unchanged. |
publicSummary | string | Customer-facing summary shown on the ticket. Pass "" or blank to clear. Omit or pass null to leave unchanged. |
| Status | Body |
|---|---|
| 200 | Full TicketDTO with the updated state. |
| 400 | ProblemDetail — request body had no non-null field. |
| 401 | ProblemDetail — missing, unknown, or revoked API key. |
| 403 | ProblemDetail — key lacks a write scope. |
| 404 | Ticket not found, or not visible to this key (with write:own, a ticket another key created). |
| 422 | ProblemDetail — status is not a valid workflow transition from the current status. |
curl -X PATCH https://punchline.example.com/api/v1/external/tickets/KEN-12 \
-H "X-Punchline-Key: pnchl_live_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuVwXy" \
-H "Content-Type: application/json" \
-d '{"status": "In Progress"}'
curl -X PATCH https://punchline.example.com/api/v1/external/tickets/KEN-12 \
-H "X-Punchline-Key: pnchl_live_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuVwXy" \
-H "Content-Type: application/json" \
-d '{"publicSummary": "We have identified the root cause and a fix is in progress."}'
Status names come from the project's configured workflow. Default transitions:
OPEN → In Progress, Done
In Progress → Review, Open, Done
Review → Done, In Progress
Done → Open
Ask a Punchline admin to share the exact status names for your project. An invalid transition returns 422 with a description of the allowed next states.