← 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 | IMPROVEMENT | IDEAnoBUG
priorityCRITICAL | HIGH | MEDIUM | LOWnoMEDIUM
pageUrlstringno
userAgentstringno
appVersionstringno
labelsarray of strings (≤ 100 items, each ≤ 100 chars)no
idempotencyKeystring (≤ 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.

Responses

StatusBody
201TicketDTO of the newly created ticket
200TicketDTO of an existing ticket — idempotent replay
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.

Responses

StatusBody
200Full TicketDTO with the updated state.
400ProblemDetail — request body had no non-null field.
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 from the current status.

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

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.