Skip to main content

API overview and conventions

Every Spyrl client (web, mobile, kiosk, integrations, devices and the platform console) uses REST over HTTPS with JSON bodies. This page defines the rules that every endpoint follows. The endpoint list is in the endpoint catalogue, and domain events and webhooks are in Events & webhooks. The request pipeline behind these rules (tenant resolution, guards, unit of work, outbox) is described in System design.

1. API surfaces​

SurfaceBase URLClientsVersioningRelease
Internal app APIhttps://acme.spyrl.app/api/v1 (the tenant's host, or its custom domain)Web SPA, mobile app, kioskShips with the SPA and the mobile minimum version; may change between releasesR0
Public APIhttps://api.spyrl.app/v1Client systems and partnersStable /v1; additive changes onlyR2
Control-plane APIhttps://cp.spyrl.app/api/v1Platform console (Spyrl staff), provisioning automation, self-serve sign-upInternalR0
Ingest APIhttps://ingest.spyrl.app/devices/:serialBiometric devices (ADMS-style push) and the sync agentPer device protocolR1 (CSV/API), R2 (gateway)
Public endpointshttps://api.spyrl.app/public/...Mobile first run (company code), certificate verificationStableR0

The public API is a curated subset of the internal API with the same resource paths and shapes. For example, GET /api/v1/emp/employees on the tenant host and GET /v1/emp/employees on api.spyrl.app return the same representation. Endpoints exposed publicly are marked Public in the catalogue.

acme and spyrl.app are placeholders. The domain is not registered.

2. Authentication by client type​

ClientCredentialHow it is sentLifetimeNotes
Web SPAAccess JWT (ES256) + refresh tokenAccess token in memory, sent as Authorization: Bearer; refresh token in an httpOnly, Secure, SameSite=Strict cookie scoped to /api/v1/iam/auth15 min access; refresh rotates on every use, 30-day idle limit per tenant policyRefresh calls need a CSRF header (Spyrl-CSRF, double-submit). Reuse of an old refresh token revokes the whole token family.
Mobile appAccess JWT + refresh token, bound to a registered deviceAuthorization: Bearer; Spyrl-Device-Id and Spyrl-App-Version headers on every callAs web; refresh kept in expo-secure-storeA new device may need approval (iam.device.approve). Below the minimum version the API returns 426.
KioskKiosk token (a long-lived session on a KIOSK device)Authorization: Bearer + Spyrl-Device-IdUntil revokedLocation-scoped and punch-only (mob.kiosk.operate, att.punch.create_on_behalf@LOCATION). Every other route returns 403.
Public API clientOAuth 2.0 client credentialsPOST https://api.spyrl.app/oauth/token with client_id and client_secret, then Authorization: Bearer1 h access token, no refreshOne client per tenant integration (iam.api_client.manage). Requested scopes are permission keys from the RBAC matrix, with ORG or LOCATION scope only. Optional IP allowlist.
Biometric devicePer-device secretHMAC header Spyrl-Device-Signature over the body, or the vendor's protocol through the gatewayRotated by the adminMachine permission att.punch.ingest only
Platform consolePlatform IdP session with mandatory MFASeparate identity system8 hNever valid on tenant hosts. Support access to a tenant uses a time-boxed grant (plt.support.request, approved through plt.support_access.approve).
Webhook receivers—Spyrl signs outbound calls (Spyrl-Signature)—See Events & webhooks

Access-token claims: sub (user ID), tid (tenant ID), sid (session ID), roles_hash, amr (pwd, otp, sso), dev (device ID for mobile and kiosk). Effective permissions are not in the token; the API loads them by roles_hash (see System design). GET /api/v1/iam/me/permissions returns them to the UI.

MFA step-up: sensitive actions (approve or lock a pay run, change bank details, export the audit log) require an amr that includes otp within the last 12 hours. Otherwise the API returns 401 with problem type mfa-step-up-required.

3. Tenant resolution​

The tenant is never taken from a request body, a query parameter or a client-chosen header.

SurfaceSource of the tenant
Internal app APIThe request host (acme.spyrl.app or a verified custom domain) → slug → tenant ID and cell, through the cached control-plane lookup. The JWT's tid must match.
Mobile first runGET https://api.spyrl.app/public/tenants/resolve?code=ACME01 returns the tenant host, display name and branding. The app then calls the tenant host.
Public APIThe tid claim of the client-credentials token
IngestThe registered device serial
Control planeNot tenant-scoped; tenant IDs appear in paths

A host that resolves to another cell returns 421 Misdirected Request with problem type wrong-cell (only during a tenant move). A suspended tenant gets read-only access: writes return 403 tenant-suspended, except payroll exports, which stay available (ADR-013 in the decision log).

4. Resource conventions​

RuleExample
Paths are /<module>/<resource>, using the lowercase module code and plural kebab-case resources/att/punches, /lve/ledger-entries, /pay/runs
Item paths use :id (UUIDv7)/lve/requests/:id
Sub-resources nest one level at most/pay/runs/:id/employees
State transitions are POST to a verb sub-path; they are never a PATCH of statusPOST /pay/runs/:id/calculate, POST /lve/requests/:id/withdraw
Approval decisions go through the NTF task inbox, for every request typePOST /ntf/tasks/:id/approve
Self-service uses the same endpoints, filtered to the caller's scope; employeeId=me is acceptedGET /lve/balances?employeeId=me
JSON property names are camelCasefromDate, leaveTypeId
Enumerations are UPPER_SNAKE strings"status": "SUBMITTED"
IDs are UUID strings; human numbers are separate fields"id": "0193…", "claimNo": "CL-0042"
Instants are ISO 8601 with offset; business dates are YYYY-MM-DD; times are HH:mm"punchedAt": "2026-11-04T09:02:11+05:30"
Money is integer paise in fields ending in Paise"amountPaise": 125000 (₹1,250.00)
Decimals (days, rates, percentages) are strings"days": "1.50", "rate": "12.0000"
Durations are integer minutes in fields ending in Minutes"workedMinutes": 492
Absent optional values are omitted; null means "explicitly empty" in a PATCH—
Sensitive fields are masked unless the caller holds the unmasking permission"panMasked": "XXXXXX123F" without emp.sensitive.read
Every response carries Spyrl-Request-Id; the API accepts and echoes W3C traceparent—
Accept-Language selects message language (English first)—

5. Pagination, filtering, sorting and sparse fields​

5.1 Cursor pagination​

All list endpoints are cursor-paginated. Offsets are not supported, because they are slow on large tables and unstable while data changes.

GET /api/v1/att/days?employeeId=me&workDate[gte]=2026-11-01&workDate[lte]=2026-11-30&limit=31
{
"data": [
{ "id": "01934a10-7c2e-7a51-9f00-1b2c3d4e5f60", "workDate": "2026-11-01", "status": "W", "paidFraction": "1.00" }
],
"page": {
"limit": 31,
"nextCursor": "eyJrIjpbIjIwMjYtMTEtMDEiLCIwMTkzNGExMC03YzJlIl19",
"hasMore": false
}
}
  • limit defaults to 50, maximum 200 (500 for att/days and att/punches exports).
  • The cursor is opaque (base64 of the sort key and the last ID). It is valid only with the same filters and sort, and for 24 hours.
  • includeTotal=true adds page.total, computed with a capped count (up to 10,000; above that the API returns "totalIsEstimate": true).

5.2 Filters​

Filters are query parameters named after fields. Range and set operators use a bracket suffix.

FormMeaning
status=SUBMITTEDEquality
status[in]=SUBMITTED,IN_APPROVALSet membership
workDate[gte]=2026-11-01&workDate[lte]=2026-11-30Range (gt, gte, lt, lte)
q=raviFull-text search where the resource supports it (PostgreSQL FTS + trigram)
locationId=…&departmentId=…Organisational filters; always intersected with the caller's scope

Only documented filters are accepted. An unknown filter returns 400 validation-failed, so a typo never silently returns unfiltered data.

5.3 Sorting, sparse fields and expansion​

ParameterExampleNotes
sortsort=-createdAt,employeeCodeComma-separated; - for descending. Each list documents its sortable fields; the ID is always appended as a tie-breaker.
fieldsfields=id,status,fromDate,toDateSparse fieldsets. id is always returned.
expandexpand=employee,leaveTypeEmbeds small summaries of related resources (name, code). It never embeds sensitive data.

6. Errors (RFC 9457 problem details)​

Errors use application/problem+json.

{
"type": "https://docs.spyrl.app/problems/leave-insufficient-balance",
"title": "Insufficient leave balance",
"status": 422,
"detail": "Casual Leave balance is 1.00 day; the request needs 3.00 days.",
"instance": "/api/v1/lve/requests",
"code": "lve.insufficient_balance",
"requestId": "req_01934a2b9c7e",
"errors": [
{ "pointer": "/leaveTypeId", "code": "lve.insufficient_balance", "message": "Balance 1.00, requested 3.00" }
]
}
Statustype (suffix)When
400validation-failedThe body, query or path fails the zod contract; errors[] lists each field (JSON Pointer)
401unauthenticated, token-expired, mfa-step-up-requiredMissing or invalid token, or step-up needed
403permission-denied, out-of-scope, entitlement-missing, tenant-suspended, segregation-of-dutiesAuthorisation failures; every one is audited
404not-foundMissing, or not visible in the caller's scope (we don't reveal existence)
409conflict, invalid-state-transition, idempotency-in-flightFor example calculating a locked run
412precondition-failedIf-Match doesn't match the current ETag
413payload-too-largeBodies over 1 MB (files go to S3)
421wrong-cellTenant moved to another cell
422business-rule-violated, idempotency-key-reused, and domain codes such as leave-insufficient-balance, attendance-outside-geofence, claim-limit-exceededValid input that breaks a business rule
426upgrade-requiredThe mobile app is below the minimum version; upgradeUrl is included
429rate-limitedSee §8
500internal-errorUnexpected; includes requestId only
503maintenance, dependency-unavailablePlanned maintenance or a provider outage; includes Retry-After

code is a stable, machine-readable identifier (<module>.<reason>). Clients branch on code, never on title or detail, which are localised.

7. Idempotency and concurrency​

7.1 Idempotency keys​

  • Send Idempotency-Key: <client-generated UUID> on any POST that creates something or changes state. It is required on: punches, leave and attendance requests, claim submission, all /pay/runs actions, /ntf/tasks/:id/* decisions, imports, file registration, public API POSTs and control-plane provisioning. It is optional elsewhere.
  • The key is scoped to (tenant, caller, method, path template) and kept for 24 hours (sys_idempotency_key).
  • Same key and same body: the stored response is replayed with header Idempotency-Replayed: true.
  • Same key and a different body: 422 idempotency-key-reused.
  • Same key while the first request is still running: 409 idempotency-in-flight with Retry-After: 1.
  • The mobile offline queue uses the action's clientRef (UUIDv7) as the key, so retries after a network drop are safe.

7.2 Optimistic concurrency​

Single resources return an ETag (the row version). PATCH and state-changing POSTs on an item accept If-Match; a stale value returns 412 precondition-failed. The web app sends If-Match on every edit form.

8. Rate limits​

Limits are Redis token buckets. They protect neighbours in pooled cells (ADR-002, ADR-003).

BucketDefault limitBurst
Per user, internal API300 requests/min60
Per tenant, internal API (all users)3,000 requests/min per 100 active employees, minimum 3,000500
Per public API client600 requests/min100
Login and password reset, per IP and per identifier10/min5
Punch endpoints, per employee10/min5
Exports and async jobs, per tenant20 concurrent jobs—
Ingest, per device60 batches/min20

Responses include RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds). A 429 includes Retry-After. Enterprise tenants can have higher limits through a control-plane entitlement override.

9. Versioning and deprecation​

SurfacePolicy
Internal /api/v1Deployed together with the SPA. Breaking changes are allowed only when the web app ships with them and the mobile minimum version is raised; otherwise the server keeps both shapes (expand/contract, ADR-010).
Public /v1Additive only: new endpoints, new optional fields, new enum values (clients must tolerate unknown values). Removing or renaming anything needs /v2.
DeprecationAnnounced at least 6 months ahead in the changelog and by email to API-client owners. Deprecated endpoints return Deprecation (RFC 9745) and Sunset (RFC 8594) headers plus a Link to the migration guide.
Events and webhooksPayload schemas are versioned (schemaVersion); a breaking change emits both versions during the deprecation window. See Events & webhooks.
MobileThe API enforces a minimum app version per platform (426), set by plt.release.rollout.

10. File uploads (presigned URLs)​

Files never pass through the API process. The client registers the file, uploads straight to S3, then confirms.

POST /api/v1/sys/files
Idempotency-Key: 0193b2c4-11aa-7f00-8c3e-6a1b2c3d4e5f
Content-Type: application/json

{ "purpose": "CLAIM_BILL", "fileName": "lunch-receipt.jpg", "contentType": "image/jpeg", "sizeBytes": 284113 }
{
"id": "0193b2c4-2f10-7d22-a1b0-77c1d2e3f4a5",
"status": "PENDING_UPLOAD",
"upload": {
"method": "PUT",
"url": "https://spyrl-p1-files.s3.ap-south-1.amazonaws.com/t/01928f00-.../clm/2026/11/0193b2c4-...?X-Amz-Signature=...",
"headers": { "Content-Type": "image/jpeg", "Content-Length": "284113" },
"expiresAt": "2026-11-04T09:07:11Z"
}
}

Then PUT the bytes to upload.url, and call POST /api/v1/sys/files/:id/complete. The file is scanned (status moves to CLEAN or INFECTED). Only a CLEAN file can be attached to a claim line, document or punch. Downloads use GET /api/v1/sys/files/:id/download, which checks the owning entity's permission and redirects (302) to a 5-minute presigned URL. Size and type limits per purpose are in System design.

11. Bulk and asynchronous operations​

Anything that can take more than about 2 seconds is a job resource.

POST /api/v1/rpt/exports
Idempotency-Key: 0193b2d0-0a0b-7c0d-8e0f-101112131415

{ "datasetKey": "att.monthly_register", "format": "XLSX", "params": { "month": "2026-10", "locationId": "01929b10-..." } }
HTTP/1.1 202 Accepted
Location: /api/v1/rpt/exports/0193b2d0-5555-7aaa-8bbb-cccccccccccc
Retry-After: 5
{ "id": "0193b2d0-5555-7aaa-8bbb-cccccccccccc", "status": "QUEUED", "progressPct": 0, "links": { "self": "/api/v1/rpt/exports/0193b2d0-5555-7aaa-8bbb-cccccccccccc" } }
RuleDetail
StartPOST returns 202 with a Location header and a job body
PollGET the job: QUEUED → RUNNING (with progressPct) → SUCCEEDED / FAILED / CANCELLED
Resultresult (counts, warnings) and resultFileId for downloads; failures carry a problem+json error
Push instead of pollThe in-app notification (rpt.export.ready) and, for public API clients, webhooks
CancelPOST /…/:id/cancel where the job supports it
Generic jobsImports, recomputes and other long tasks use GET /api/v1/sys/jobs/:id
Bulk writesSynchronous bulk endpoints (for example bulk approve, bulk shift assignment) accept up to 500 items and return 200 with a per-item result list (ok, problem); larger batches must use an import job
Dry runsImports accept dryRun=true and return the validation report without writing

12. OpenAPI 3.1 from zod contracts​

  • Each endpoint is declared once in packages/contracts (method, path, params, query, body, responses, permission, public: true/false). Controllers bind to that declaration, so the code and the spec can't drift.
  • The build emits openapi.internal.json and openapi.public.json. The public document includes only endpoints marked public, with the OAuth 2.0 security scheme and scopes named after permission keys.
  • CI runs an OpenAPI diff against the last released public spec. Any breaking change to /v1 fails the build.
  • Contract tests replay recorded requests against the running API, and the mobile app's oldest supported version runs its contract suite against every release.
  • Decision pending: zod's native JSON Schema output versus zod-to-openapi (see the decision log).

13. Other HTTP rules​

TopicRule
CORSThe internal API allows only the tenant's own origins (subdomain and verified custom domain). The public API allows no browser origins by default.
CachingGET responses carry ETag and Cache-Control: private, no-cache; payslips and other sensitive downloads use no-store.
Compressiongzip and br
Timeouts30 s at the load balancer; synchronous handlers are budgeted at 2 s
Security headersHSTS, CSP (web), X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer
HealthGET /healthz (liveness) and GET /readyz (dependencies) on each process; not tenant-scoped