Appearance
Migrating from v1 to v2
Side-by-side examples for the patterns that change when you move an integration from /api/v1/* to /api/v2/*. Auth headers are the same on both versions; the differences are in URL shape, query parameters, and response envelope.
v1 isn't going away
v1 is frozen, not deprecated. Existing integrations keep working indefinitely. This guide is for when you're ready to take advantage of the v2 improvements — unified query shape, RFC 7807 errors, the live OpenAPI spec.
Response envelope
v1
json
{
"didSucceed": true,
"data": [ /* records */ ],
"status": 200,
"totalCount": 145,
"pageCount": 50,
"offset": 0
}v2 (list)
json
{
"data": [ /* records */ ],
"meta": { "total": 145, "limit": 50, "offset": 0, "has_more": true }
}v2 (single record)
json
/* the entity, directly — no envelope */
{ "Id": 102345, "ProjectName": "Acme HQ Buildout", ... }Migration: stop reaching into .data for single-record responses; the body IS the entity. For lists, meta.total replaces totalCount; meta.limit replaces pageCount; meta.has_more is new.
Listing with filter
v1 — resource-specific query params, often loosely typed:
http
GET /api/v1/projects?BusinessState=Open&page=1&page_size=50&sort=DateCreated&sort_asc=falsev2 — unified where / order, qs bracket notation in URLs:
http
GET /api/v2/projects?where[BusinessState]=Open&limit=50&offset=0&order[0][0]=DateCreated&order[0][1]=DESCOr — the v2-native way for non-trivial queries — JSON body:
http
POST /api/v2/projects/query
Content-Type: application/json
{
"where": { "BusinessState": "Open" },
"order": [["DateCreated", "DESC"]],
"limit": 50,
"offset": 0
}Migration patterns:
page=N&page_size=K→limit=K&offset=(N-1)*Ksort=X&sort_asc=false→order=[["X","DESC"]]- Per-resource filter params (
?BusinessState=Open) →where[BusinessState]=Openeverywhere
Get one
v1
http
GET /api/v1/projects/102345
→ { "didSucceed": true, "data": { ...project }, "status": 200 }v2
http
GET /api/v2/projects/102345
→ { "Id": 102345, "ProjectName": "...", ... }Drop the .data unwrap. Errors come back as RFC 7807 problems instead of didSucceed: false envelopes (see below).
Create
v1
http
POST /api/v1/projects
{ "ProjectName": "New Job" }
→ { "didSucceed": true, "data": { "Id": 102345 } | { ...newProject }, "status": 201 }The data shape was inconsistent across v1 resources — sometimes { Id }, sometimes the full row.
v2
http
POST /api/v2/projects
{ "ProjectName": "New Job" }
→ HTTP/1.1 201 Created
Location: /api/v2/projects/102345
{ "Id": 102345, "ProjectName": "New Job", ... }Returns the full record in the same shape as GET /api/v2/projects/{id} — re-fetched server-side after the write completes. Same shape every time.
Update
v1
http
PUT /api/v1/projects/102345
{ "ProjectName": "Updated" }
→ { "didSucceed": true, "data": ..., "status": 200 }v2
http
PATCH /api/v2/projects/102345
{ "ProjectName": "Updated" }
→ { ...full updated entity }PATCH instead of PUT (partial update is the semantic match), and the response is the canonical entity shape.
Delete
v1
http
DELETE /api/v1/projects/102345
→ { "didSucceed": true, "data": null, "status": 200 }v2
http
DELETE /api/v2/projects/102345
→ HTTP/1.1 204 No ContentEmpty body, status code is the signal.
Incremental sync (since)
v1 — path-form timestamp:
http
GET /api/v1/projects/since/2026-01-01T00:00:00Z?page=1&page_size=100v2 — query parameter:
http
GET /api/v2/projects?since=2026-01-01T00:00:00Z&limit=100Equivalent semantics (records where DateModified >= since), simpler URL, composable with other filters.
Sub-collections
v1 had dedicated nested paths:
http
GET /api/v1/projects/102345/list-items
GET /api/v1/projects/102345/submittalsv2 treats most sub-resources as top-level resources you filter:
http
GET /api/v2/list-items?where[ProjectId]=102345
GET /api/v2/submittals?where[ProjectId]=102345Convenience aliases are kept where they made sense:
http
GET /api/v2/projects/102345/submittals # same data, scoped path
GET /api/v2/projects/102345/project-plan # the project's plan recordCombined invoices (regular + AIA)
v1
http
GET /api/v1/invoices/all
→ {
"didSucceed": true,
"data": [
{ "Id": "AIA42", "InvoiceType": "AIA", ... }, /* AIA Id prefixed with "AIA" */
{ "Id": 101, ... }
]
}v2 — preserves numeric Ids, uses an InvoiceType discriminator:
http
GET /api/v2/invoices/all
→ {
"data": [
{ "Id": 42, "InvoiceType": "AIA", ... },
{ "Id": 101, "InvoiceType": "Standard", ... }
],
"meta": { ... }
}(Id, InvoiceType) is the unique key — Id alone is no longer enough because both tables auto-increment independently.
Errors
v1
json
HTTP/1.1 404 Not Found
Content-Type: application/json
{ "didSucceed": false, "data": null, "status": 404, "message": "Project not found" }v2
json
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Project not found",
"status": 404,
"code": "project_not_found",
"detail": "No project with Id 99999999 exists."
}Migration: check the response Content-Type to dispatch. application/problem+json → v2-style structured error; otherwise treat as success body. code is the machine-readable identifier (stable across runs); detail is the human-readable message.
For validation errors, an additional issues array lists each failed field — see Responses & Errors.
Scopes — names changed in a few places
A handful of resources were renamed for clarity. The OAuth scopes follow the resource slug:
| v1 scope | v2 scope |
|---|---|
tickets:* | service-tickets:* |
time:* | time-entries:* |
items:* (catalog) | items:* (same — still maps to ServiceCatalogItems) |
| (new) | purchase-items:* (PO line items, was previously bundled under items) |
| (new) | aia-invoices:* (was previously under invoices) |
list-items:* | list-items:* (unchanged) |
Existing OAuth clients granted the legacy scopes keep working on v1 (which still uses them). When you register or update a client for v2 use, request the v2 scope names. See Scopes for the full list.
What stays the same
- OAuth flows — same endpoints, same grant types. Or use direct kAuth per Direct kAuth.
- PascalCase field names —
ProjectName,BusinessState,DateCreated. v2 passes through query-engine's shape. - Date/time format — ISO-8601 strings everywhere (including time-of-day fields like
StartTime/EndTime). - Enum values — plain strings (
"Open","Active"). Never hash-prefixed.
Migration strategy
The pragmatic approach for an existing v1 integration:
- Keep v1 working. Don't break what's running.
- Add a thin abstraction in your client code so the API version is a configuration switch.
- Migrate one endpoint at a time — start with reads (lowest risk), confirm the response shape change, then move to writes.
- Switch when ready. Flip the config flag once every endpoint your integration touches is on v2.
The two versions are mounted on the same host — /api/v1/* and /api/v2/* — so you can run them side-by-side during the migration.