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 | ENHANCEMENT | IDEA | TECHNICAL | 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 | — |
IMPROVEMENT was renamed to ENHANCEMENT. The old value is still accepted on
input and stored as ENHANCEMENT; responses always return the new name.
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. Its links array omits any link to a ticket in another project. |
| 200 | TicketDTO of an existing ticket — idempotent replay. Same links filtering. |
| 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. |
priority | string | One of CRITICAL, HIGH, MEDIUM, LOW. Records a triage ruling — confirming the current value counts as a ruling. Omit or pass null to leave unchanged. |
labels | array | Label names. Replaces the ticket's labels with exactly this list; unknown names are created, as at creation. Pass [] to clear all labels. Omit or pass null to leave them unchanged. Individual names must be non-blank and at most 100 characters. Surrounding whitespace is stripped, including non-breaking spaces, so " api " resolves to the existing api rather than founding a second label. |
title | string | Replaces the ticket title. Trimmed; blank (after trimming) is rejected with 400 rather than leaving the ticket titleless. Omit or pass null to leave unchanged. |
description | string | Replaces the reporter-authored body only. Every ticket's stored description carries a **Reported by** … via … provenance header written at creation; a description patch preserves that header and rewrites only the text after it. Pass "" to clear the body and keep just the header. Omit or pass null to leave unchanged. |
title and description are applied first, then labels, then
priority, then status — so a single call can correct the content, correct
a label, triage a ticket and close it, and the move happens on the corrected
labels rather than the stale ones.
⚠️ Omitting labels and sending [] are different instructions. An omitted
field leaves the labels alone; [] clears them. Labels drive auto-assignment
routing, so a client that always sends the full body must send the current list
back, not an empty one.
| Status | Body |
|---|---|
| 200 | Full TicketDTO with the updated state. Its links array omits any link to a ticket in another project. |
| 400 | ProblemDetail — request body had no non-null field, priority was not one of the four values, a label name was blank or exceeded 100 characters, or title was blank after trimming. |
| 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, or the project requires triage before close and the ticket has no ruled priority. |
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."}'
Some projects require a ticket's priority to be ruled on before it can reach a
terminal status. A ticket you create through this API arrives untriaged —
the priority you send at creation is recorded as the reporter's proposal, not
as a ruling. Send priority alongside status to rule and close together:
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 '{"priority": "HIGH", "status": "Done"}'
Without priority, such a project answers 422:
Ticket KEN-12 cannot move to 'Done' until its priority has been triaged.
Labels are not decoration — auto-assignment routes on them, and a label filed by
an integration ("undeployed", say) goes stale the moment the work ships. Send
the list you want the ticket to end up with:
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 '{"labels": ["backend", "deployed"], "status": "Done"}'
Re-sending an unchanged set is a no-op and records nothing, so a retry is safe.
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.
After creating a ticket, the same API key can link it to another ticket in the
same project — "blocks", "duplicates", "relates to". This needs a write
scope (403 otherwise); with the default write:own both the URL ticket
and the target ticket must be visible to this key, while write:all can link
any two tickets in the project.
Links come back under two different field names depending on which endpoint you
called: GET and the list endpoint return linkedTickets (each entry a
displayId, title, status and relation), while POST and PATCH
return a TicketDTO whose links array carries the same relationships in a
source/target shape.
Cross-project links are omitted from every external response. This API can
only ever create links within your own project, but a staff member can link one
of your tickets to a ticket in another project from the Punchline UI. Those
links are filtered out of linkedTickets and links alike — so a link you
cannot see here may still exist. Deleting one is not possible through this API
either; DELETE can only address a ticket in your own project.
| Value | Meaning |
|---|---|
BLOCKS | The URL ticket blocks the target ticket. |
BLOCKED_BY | The URL ticket is blocked by the target ticket. |
DUPLICATES | The URL ticket duplicates the target ticket. |
DUPLICATE_OF | The URL ticket is a duplicate of the target ticket. |
RELATES_TO | A non-directional relation between the two tickets. |
These are the same five values linkedTickets[].relation returns on the
ticket read/list endpoints above, so a relation round-trips: link PRJ-1 to
PRJ-2 with BLOCKED_BY, and GET /api/v1/external/tickets/PRJ-2 shows
PRJ-1 in its own linkedTickets with relation: "BLOCKS".
POST /api/v1/external/tickets/{displayId}/linksPOST /api/v1/external/tickets/PRJ-1/links
X-Punchline-Key: <your key>
Content-Type: application/json
| Field | Type | Required | Default |
|---|---|---|---|
targetDisplayId | string | yes | — |
relation | enum (see above) | yes | — |
closeAsDuplicate | boolean | no | false |
{
"targetDisplayId": "PRJ-2",
"relation": "BLOCKED_BY"
}
closeAsDuplicate — destructive, sends emailSetting closeAsDuplicate: true requires relation: "DUPLICATES" — it
is rejected on every other relation, including DUPLICATE_OF, because that
flipped orientation would silently close the other ticket, one your request
only named via targetDisplayId.
When accepted, it closes the URL ticket (moves it to the project's terminal workflow status) and emails that ticket's reporter a duplicate-merge notification linking to the target ticket. This happens synchronously as part of the same request — there is no confirmation step and no undo. Only set this flag when you mean to close the ticket named in the URL.
{
"targetDisplayId": "PRJ-2",
"relation": "DUPLICATES",
"closeAsDuplicate": true
}
| Status | Body |
|---|---|
| 201 | ExternalLinkedTicketDTO — the target ticket, with its relation as seen from the URL ticket |
| 400 | ProblemDetail — unknown relation value, self-link (targetDisplayId equals the URL ticket), or closeAsDuplicate: true with a relation other than DUPLICATES |
| 401 | ProblemDetail — missing, unknown, or revoked API key |
| 403 | ProblemDetail — key lacks a write scope |
| 404 | ProblemDetail — either ticket not found, or not visible to this key (with write:own, a ticket neither key created) |
| 409 | ProblemDetail — the project is archived, that link already exists, or (with closeAsDuplicate) a merge guard rejected the close (source already closed, target already closed, or the project has no terminal status) |
DELETE /api/v1/external/tickets/{displayId}/links/{targetDisplayId}relation is a required query parameter — omitting it returns 400. It
must match how the link reads from the ticket named in the URL — the same
value linkedTickets[].relation would show for it. RELATES_TO is the only
relation that reads the same from both ends, so it is the only one where
either ticket's URL accepts the same relation value. Every other relation
is orientation-specific: a link stored as BLOCKS reads BLOCKED_BY from
the target's side, so deleting it from there requires relation=BLOCKED_BY,
not relation=BLOCKS — passing the wrong one 404s and leaves the link alone,
even though DUPLICATES/DUPLICATE_OF looks superficially symmetric like
BLOCKS/BLOCKED_BY does.
DELETE /api/v1/external/tickets/PRJ-1/links/PRJ-2?relation=BLOCKED_BY
X-Punchline-Key: <your key>
| Status | Body |
|---|---|
| 204 | No body. |
| 400 | ProblemDetail — missing relation query parameter |
| 401 | ProblemDetail — missing, unknown, or revoked API key |
| 403 | ProblemDetail — key lacks a write scope |
| 404 | ProblemDetail — either ticket not found or not visible to this key, or no link with that relation exists between them |
| 409 | ProblemDetail — the project is archived |
Deleting a link never closes a ticket or sends email, even if the link being
removed was originally created with closeAsDuplicate.