Conventions#

These hold across the whole surface. Reading this once saves rediscovering it per resource.

Versioning#

The version is in the path: /api/public/v1/.

Additive changes — new fields, new endpoints, new optional parameters, new enum values — ship inside v1. Your client must tolerate them, which takes two habits: ignore response fields you don’t recognise, and don’t fail closed on an unfamiliar enum value.

Removals, renames, retypes, and anything that tightens validation bump the version, with an overlap window during which both are live.

List responses#

Lists are cursor-paginated and always come back in the same envelope:

{
  "object": "list",
  "data": [ { "object": "shift", "id": "0194d1c0-…", "…": "…" } ],
  "has_more": true,
  "next": "https://app.shiftavo.com/api/public/v1/shifts/?cursor=…"
}
  • next is an opaque URL. Follow it as-is; do not construct or parse cursors.

  • Stop when has_more is false.

  • page_size controls the page length.

The object discriminator#

Every resource carries an "object" field naming its type — "shift", "location", "employee". Branch on it rather than on the shape of the payload, especially inside webhook handlers and mixed collections.

Errors#

One envelope, everywhere:

{
  "error": {
    "type": "conflict_error",
    "code": "not_cancellable",
    "message": "…",
    "details": [],
    "param": "start_date"
  }
}
  • details is always present — a list of field-level failures, empty when the error has no field breakdown.

  • param appears only when exactly one field is at fault.

There are seven types. Each maps to one status except invalid_request_error, the catch-all for the non-tabulated 4xx, where the code carries the distinction:

type

Status

Notes

invalid_request_error

400 · 405 · 406 · 415

code = invalid_request / method_not_allowed / not_acceptable / unsupported_media_type

idempotency_error

400

Idempotency-Key reused with a different body (idempotency_key_reused)

authentication_error

401

missing / invalid / expired token

permission_error

403

insufficient_scope, and friends

not_found

404

also returned instead of 403 for records outside your company — no existence leak

conflict_error

409

state conflicts: duplicate, in_use, not_deletable, an in-flight idempotent replay

rate_limit_error

429

carries Retry-After

Branch on type for handling class, on code for the specific case. Both are stable; message is not — treat it as human-readable only.

Idempotency#

Send an Idempotency-Key header on creates. A retried request then replays the first result instead of creating a second record.

Reusing a key with a different body is a 400 idempotency_key_reused — the key identifies one request, not one endpoint.

Request tracing#

Every response, success and error alike, carries an X-Request-Id header. Log it. Send your own X-Request-Id and it is echoed back, so a trace id can travel end to end. Quote it in any support request.

Lifecycle transitions#

A transition fires as a POST to an action sub-path: POST …/<id>/publish/, …/cancel/, …/revoke/.

A transition that cannot apply — cancelling a draft, publishing something already published — is a 409 with a specific code (not_cancellable, not_publishable, not_unpublishable). It is never a 200 that silently did nothing.

Deleting#

DELETE returns a stub, not a 204:

{ "id": "0194d1c0-…", "object": "shift", "deleted": true }

A DELETE never takes a body. Everything a delete needs is in the path or the query string, so a generated client can express all of them. (HTTP leaves a DELETE body’s semantics undefined and OpenAPI 3.0 tells consumers to ignore one, so a body field here would silently disappear from your client and come back as a 400 the schema couldn’t explain.)

Delete is not the soft verb#

Where a resource offers both, they are not synonyms, and the wrong choice is not recoverable:

Resource

DELETE …/<id>/

The soft verb

shifts

removes the shift; assignees of a published shift are notified and any live marketplace offer is withdrawn first

POST …/cancel/ — the shift stays, marked cancelled

employees

terminal removal: access revoked, the person leaves every collection and a later GET is a 404. The record is retained internally so timesheets and leave keep their references

POST …/deactivate/ (reversible) · POST …/offboard/ (dated termination + settlement)

leave_requests

only while pending or declined — a confirmed or revoked request carries a ledger booking, so it is a 409 not_deletable

POST …/revoke/ — undoes the effect, keeps the record

Recurring records#

Deleting one occurrence of a recurring shifts or availability record needs an explicit scope:

DELETE …/<id>/?scope=this|following|all        # shifts
DELETE …/<id>/?scope=single|following|all      # availability

Omit it on a series and you get a 409 delete_scope_required rather than a guess about what you meant.

Rate limits#

A 429 carries a Retry-After header. Honour it, and back off exponentially on repeats.