GitHub
Reference

Conventions

URL structure, JSON encoding, pagination via cursor, filtering via query params, and soft-delete semantics across the core API.

Every core resource (/api/v1/issues, /api/v1/projects, /api/v1/workflows, …) follows the same conventions. Once you’ve used one, the rest are predictable.

URL structure

http(s)://<host>/api/v1/<resource>[/<id>][/<sub-resource>[/<sub-id>]]
PatternExample
ListGET /api/v1/issues
ReadGET /api/v1/issues/{id}
CreatePOST /api/v1/issues
UpdatePATCH /api/v1/issues/{id} (partial — fields not in body are unchanged)
ReplacePUT /api/v1/issues/{id} (less common; rejects unknown fields)
DeleteDELETE /api/v1/issues/{id}
Bulk deleteDELETE /api/v1/issues with [ids...] body
Sub-resource listGET /api/v1/issues/{id}/comments
Sub-resource actionPOST /api/v1/issues/{id}/dispatch

PATCH is the default update verb. The body should include only fields you want to change — null is treated as “set to null” (not “leave alone”), which catches you out occasionally. To clear a field, send null explicitly.

Content type

JSON in, JSON out. The server expects Content-Type: application/json on POST/PATCH/PUT/DELETE with bodies. Empty bodies are fine for reads and most deletes.

curl -X PATCH http://localhost:3000/api/v1/issues/iss_42 \
  -H "Authorization: Bearer $PFAI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"in_progress","priority":"high"}'

The forge release-asset upload (POST /api/v1/forge/{owner}/{repo}/releases/{tag}/assets) takes multipart/form-data. Inbound webhooks accept arbitrary bodies (the webhook provider decides parsing).

Pagination

List endpoints use cursor-based pagination (utils.GetPaginationParams). Four query params:

ParamDefaultMeaning
limit100Max items per page; capped server-side per resource
cursoremptyOpaque token from a previous response’s nextCursor
updatedSinceemptyISO-8601 — only return rows updated after this timestamp (incremental sync)
includeCountfalseWhen true, response includes totalCount (additional SELECT COUNT(*) — slower)

Response shape:

{
  "items": [ { ... }, { ... } ],
  "nextCursor": "eyJpZCI6Imlzc18xMjMifQ==",
  "totalCount": 1247
}

nextCursor is empty when there are no more pages. Keep paging until empty:

CURSOR=""
while :; do
  R=$(curl -s "http://localhost:3000/api/v1/issues?limit=100&cursor=$CURSOR" \
        -H "Authorization: Bearer $PFAI_TOKEN")
  echo "$R" | jq -c '.items[]'
  CURSOR=$(echo "$R" | jq -r '.nextCursor // empty')
  [ -z "$CURSOR" ] && break
done

Cursors are stable across new inserts — pages don’t shift if rows are added between calls.

Filtering

List endpoints accept resource-specific query params. Convention is camelCase keys matching the resource’s JSON fields:

curl "http://localhost:3000/api/v1/issues?status=in_progress&priority=high&assigneeId=user_42"

Comma-separated values mean “any of”:

curl "http://localhost:3000/api/v1/issues?status=todo,in_progress,in_review"

Some resources expose convenience aliases — /issues?assignee=@me resolves to the calling user’s ID. Anything more complex (boolean expressions, JMESPath) lives client-side: fetch with rough filters, refine in your code.

Sorting

sort query param accepts field or -field (descending):

curl "http://localhost:3000/api/v1/issues?sort=-updatedAt"

Multi-key sort uses comma separation: ?sort=-priority,createdAt (priority desc, then createdAt asc). Available sort keys vary per resource — id, createdAt, updatedAt are universal.

Soft delete

Most “deletes” are soft — the row is marked deleted but retained for audit and updatedSince-based sync. Re-creating with the same name will fail with 409 until the soft-deleted row is purged. To hard-delete, pass ?purge=true (admins only).

The exceptions:

  • Comments, time entries, labels — hard-deleted (they’re cheap and frequent)
  • PR/issue auto-closes — set state to closed/done, don’t delete the row
  • Soft-deleted projects — visible in archived lists and excluded from default queries

Tenant isolation

Every table is scoped to an org via the orgId column and PostgreSQL RLS (migration_multitenancy.go). The middleware sets the org context based on the token’s claims; queries can’t cross orgs even with explicit WHERE clauses. A 404 for a resource you don’t own org-side is intentional — you’d otherwise leak existence by getting 403 instead.

Timestamps

ISO-8601 with timezone, always:

2026-05-05T14:30:45Z

The server stores TIMESTAMPTZ; clients should send and parse the same. Date-only fields (issue dueDate, sprint startDate) are YYYY-MM-DD.

IDs

All IDs are strings with a typed prefix:

PrefixResource
org_Organization
user_User
team_Team
proj_Project
iss_Issue (also <TEAM>-<number> for human display)
wf_ / exec_Workflow / execution
agent_ / agexec_Agent / agent execution
tok_PAT
int_Integration
wh_Webhook
evt_Platform event

The forge uses <owner>/<repo> slugs as the identifier in URLs (e.g. /api/v1/forge/acme/api-server/pulls/42). PR and release asset numeric IDs are int64 for backward compatibility, but the slug is the canonical handle.

See also