← Punchline
API docs/External Tickets

External Ticket Creation API

Server-to-server endpoint for creating Punchline tickets from external systems (Skinlyzer feedback widget, Sentry, Posthog, etc.).

Overview

Authentication

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-Key header also accepts a user-level key (pnchl_user_..., created on a user's own Settings page). User keys are provisioning-only — they work exclusively on POST /api/v1/external/projects and return 403 everywhere else, including every endpoint on this page. Use a project key (pnchl_live_...) here.

Scopes

Each key carries scopes that bound what it can do:

ScopeGrants
read:ownList/get tickets this key created (default).
read:allList/get every ticket in the project.
write:ownCreate tickets; patch/comment on tickets this key created (default).
write:allPatch/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.

Whoami

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.

Rate-limit headers

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.

Endpoint

POST /api/v1/external/tickets
Content-Type: application/json
X-Punchline-Key: <your key>

Request body

FieldTypeRequiredDefault
titlestring (1–500 chars)yes
descriptionstringno""
reporterEmailemailyes
reporterNamestringno(email)
typeBUG | FEATURE | TASK | ENHANCEMENT | IDEA | TECHNICALnoBUG
priorityCRITICAL | HIGH | MEDIUM | LOWnoMEDIUM
pageUrlstringno
userAgentstringno
appVersionstringno
labelsarray of strings (≤ 100 items, each ≤ 100 chars)no
idempotencyKeystring (≤ 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.

Responses

StatusBody
201TicketDTO of the newly created ticket. Its links array omits any link to a ticket in another project.
200TicketDTO of an existing ticket — idempotent replay. Same links filtering.
400RFC 7807 ProblemDetail (validation error)
401RFC 7807 ProblemDetail (auth error)
403RFC 7807 ProblemDetail — key lacks a write scope
415RFC 7807 ProblemDetail (wrong content-type)

Creating a ticket requires a write scope (write:own or write:all); the default write:own covers it.

Idempotency

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.

Sample: cURL

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"
  }'

Sample: Node fetch

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();
}

Errors

StatusWhenAction
401Missing, malformed, unknown, or revoked API keyConfirm the key, ask the admin if you need a new one
403Valid 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
404Key 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
400Body validation failedInspect the ProblemDetail's detail field
415Wrong Content-TypeSet Content-Type: application/json
429Per-key (or per-IP, for invalid keys) rate limit exceededHonor 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."

Key rotation

  1. Ask a Punchline admin to issue a new key.
  2. Deploy the new key to your service.
  3. Ask the admin to revoke the old key.

Zero-downtime as long as the new key is live before the old one is revoked.

Following up on tickets

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:

FieldType
displayIdstring (e.g. KEN-12)
titlestring
statusstring
typeenum (see request body above)
priorityenum
dueDateISO date or null
labelsarray of label names
closedboolean
publicCommentCountinteger
reporterEmailstring
createdAtISO instant
updatedAtISO instant

User identifiers, internal comments, attachment URLs, custom fields, and the activity log are intentionally omitted.

GET /api/v1/external/tickets

Filtered list of tickets — those your key submitted (read:own) or every ticket in the project (read:all). All filters are optional and AND-composed.

ParamTypeNotes
reporterEmailemailexact match, case-insensitive
statusstringexact match
typeenumexact match
createdAfterISO instantinclusive lower bound
createdBeforeISO instantexclusive upper bound
pageintegerdefault 0
sizeintegerdefault 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.

Updating a ticket

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

Request body

At least one field must be non-null.

FieldTypeNotes
statusstringWorkflow 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.
publicSummarystringCustomer-facing summary shown on the ticket. Pass "" or blank to clear. Omit or pass null to leave unchanged.
prioritystringOne of CRITICAL, HIGH, MEDIUM, LOW. Records a triage ruling — confirming the current value counts as a ruling. Omit or pass null to leave unchanged.
labelsarrayLabel 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.
titlestringReplaces the ticket title. Trimmed; blank (after trimming) is rejected with 400 rather than leaving the ticket titleless. Omit or pass null to leave unchanged.
descriptionstringReplaces 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.

Responses

StatusBody
200Full TicketDTO with the updated state. Its links array omits any link to a ticket in another project.
400ProblemDetail — 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.
401ProblemDetail — missing, unknown, or revoked API key.
403ProblemDetail — key lacks a write scope.
404Ticket not found, or not visible to this key (with write:own, a ticket another key created).
422ProblemDetailstatus is not a valid workflow transition, or the project requires triage before close and the ticket has no ruled priority.

Sample: move a ticket to "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 '{"status": "In Progress"}'

Sample: set a public summary

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."}'

Sample: triage and close in one call

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.

Sample: correct a stale label

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.

Workflow transitions

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.

Linking tickets

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.

Relation values

ValueMeaning
BLOCKSThe URL ticket blocks the target ticket.
BLOCKED_BYThe URL ticket is blocked by the target ticket.
DUPLICATESThe URL ticket duplicates the target ticket.
DUPLICATE_OFThe URL ticket is a duplicate of the target ticket.
RELATES_TOA 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}/links

POST /api/v1/external/tickets/PRJ-1/links
X-Punchline-Key: <your key>
Content-Type: application/json

Request body

FieldTypeRequiredDefault
targetDisplayIdstringyes
relationenum (see above)yes
closeAsDuplicatebooleannofalse
{
  "targetDisplayId": "PRJ-2",
  "relation": "BLOCKED_BY"
}

closeAsDuplicate — destructive, sends email

Setting 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
}

Responses

StatusBody
201ExternalLinkedTicketDTO — the target ticket, with its relation as seen from the URL ticket
400ProblemDetail — unknown relation value, self-link (targetDisplayId equals the URL ticket), or closeAsDuplicate: true with a relation other than DUPLICATES
401ProblemDetail — missing, unknown, or revoked API key
403ProblemDetail — key lacks a write scope
404ProblemDetail — either ticket not found, or not visible to this key (with write:own, a ticket neither key created)
409ProblemDetail — 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>

Responses

StatusBody
204No body.
400ProblemDetail — missing relation query parameter
401ProblemDetail — missing, unknown, or revoked API key
403ProblemDetail — key lacks a write scope
404ProblemDetail — either ticket not found or not visible to this key, or no link with that relation exists between them
409ProblemDetail — 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.