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>]]
| Pattern | Example |
|---|---|
| List | GET /api/v1/issues |
| Read | GET /api/v1/issues/{id} |
| Create | POST /api/v1/issues |
| Update | PATCH /api/v1/issues/{id} (partial — fields not in body are unchanged) |
| Replace | PUT /api/v1/issues/{id} (less common; rejects unknown fields) |
| Delete | DELETE /api/v1/issues/{id} |
| Bulk delete | DELETE /api/v1/issues with [ids...] body |
| Sub-resource list | GET /api/v1/issues/{id}/comments |
| Sub-resource action | POST /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:
| Param | Default | Meaning |
|---|---|---|
limit | 100 | Max items per page; capped server-side per resource |
cursor | empty | Opaque token from a previous response’s nextCursor |
updatedSince | empty | ISO-8601 — only return rows updated after this timestamp (incremental sync) |
includeCount | false | When 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
statetoclosed/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:
| Prefix | Resource |
|---|---|
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.