Skip to content

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=false

v2 — 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]=DESC

Or — 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)*K
  • sort=X&sort_asc=false → order=[["X","DESC"]]
  • Per-resource filter params (?BusinessState=Open) → where[BusinessState]=Open everywhere

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 Content

Empty 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=100

v2 — query parameter:

http
GET /api/v2/projects?since=2026-01-01T00:00:00Z&limit=100

Equivalent 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/submittals

v2 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]=102345

Convenience 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 record

Combined 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 scopev2 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:

  1. Keep v1 working. Don't break what's running.
  2. Add a thin abstraction in your client code so the API version is a configuration switch.
  3. Migrate one endpoint at a time — start with reads (lowest risk), confirm the response shape change, then move to writes.
  4. 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.