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
| Surface | Base URL | Clients | Versioning | Release |
|---|---|---|---|---|
| Internal app API | https://acme.spyrl.app/api/v1 (the tenant's host, or its custom domain) | Web SPA, mobile app, kiosk | Ships with the SPA and the mobile minimum version; may change between releases | R0 |
| Public API | https://api.spyrl.app/v1 | Client systems and partners | Stable /v1; additive changes only | R2 |
| Control-plane API | https://cp.spyrl.app/api/v1 | Platform console (Spyrl staff), provisioning automation, self-serve sign-up | Internal | R0 |
| Ingest API | https://ingest.spyrl.app/devices/:serial | Biometric devices (ADMS-style push) and the sync agent | Per device protocol | R1 (CSV/API), R2 (gateway) |
| Public endpoints | https://api.spyrl.app/public/... | Mobile first run (company code), certificate verification | Stable | R0 |
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
| Client | Credential | How it is sent | Lifetime | Notes |
|---|---|---|---|---|
| Web SPA | Access JWT (ES256) + refresh token | Access token in memory, sent as Authorization: Bearer; refresh token in an httpOnly, Secure, SameSite=Strict cookie scoped to /api/v1/iam/auth | 15 min access; refresh rotates on every use, 30-day idle limit per tenant policy | Refresh calls need a CSRF header (Spyrl-CSRF, double-submit). Reuse of an old refresh token revokes the whole token family. |
| Mobile app | Access JWT + refresh token, bound to a registered device | Authorization: Bearer; Spyrl-Device-Id and Spyrl-App-Version headers on every call | As web; refresh kept in expo-secure-store | A new device may need approval (iam.device.approve). Below the minimum version the API returns 426. |
| Kiosk | Kiosk token (a long-lived session on a KIOSK device) | Authorization: Bearer + Spyrl-Device-Id | Until revoked | Location-scoped and punch-only (mob.kiosk.operate, att.punch.create_on_behalf@LOCATION). Every other route returns 403. |
| Public API client | OAuth 2.0 client credentials | POST https://api.spyrl.app/oauth/token with client_id and client_secret, then Authorization: Bearer | 1 h access token, no refresh | One 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 device | Per-device secret | HMAC header Spyrl-Device-Signature over the body, or the vendor's protocol through the gateway | Rotated by the admin | Machine permission att.punch.ingest only |
| Platform console | Platform IdP session with mandatory MFA | Separate identity system | 8 h | Never 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.
| Surface | Source of the tenant |
|---|---|
| Internal app API | The 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 run | GET 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 API | The tid claim of the client-credentials token |
| Ingest | The registered device serial |
| Control plane | Not 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
| Rule | Example |
|---|---|
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 status | POST /pay/runs/:id/calculate, POST /lve/requests/:id/withdraw |
| Approval decisions go through the NTF task inbox, for every request type | POST /ntf/tasks/:id/approve |
Self-service uses the same endpoints, filtered to the caller's scope; employeeId=me is accepted | GET /lve/balances?employeeId=me |
| JSON property names are camelCase | fromDate, 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
}
}
limitdefaults to 50, maximum 200 (500 foratt/daysandatt/punchesexports).- 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=trueaddspage.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.
| Form | Meaning |
|---|---|
status=SUBMITTED | Equality |
status[in]=SUBMITTED,IN_APPROVAL | Set membership |
workDate[gte]=2026-11-01&workDate[lte]=2026-11-30 | Range (gt, gte, lt, lte) |
q=ravi | Full-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
| Parameter | Example | Notes |
|---|---|---|
sort | sort=-createdAt,employeeCode | Comma-separated; - for descending. Each list documents its sortable fields; the ID is always appended as a tie-breaker. |
fields | fields=id,status,fromDate,toDate | Sparse fieldsets. id is always returned. |
expand | expand=employee,leaveType | Embeds 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" }
]
}
| Status | type (suffix) | When |
|---|---|---|
| 400 | validation-failed | The body, query or path fails the zod contract; errors[] lists each field (JSON Pointer) |
| 401 | unauthenticated, token-expired, mfa-step-up-required | Missing or invalid token, or step-up needed |
| 403 | permission-denied, out-of-scope, entitlement-missing, tenant-suspended, segregation-of-duties | Authorisation failures; every one is audited |
| 404 | not-found | Missing, or not visible in the caller's scope (we don't reveal existence) |
| 409 | conflict, invalid-state-transition, idempotency-in-flight | For example calculating a locked run |
| 412 | precondition-failed | If-Match doesn't match the current ETag |
| 413 | payload-too-large | Bodies over 1 MB (files go to S3) |
| 421 | wrong-cell | Tenant moved to another cell |
| 422 | business-rule-violated, idempotency-key-reused, and domain codes such as leave-insufficient-balance, attendance-outside-geofence, claim-limit-exceeded | Valid input that breaks a business rule |
| 426 | upgrade-required | The mobile app is below the minimum version; upgradeUrl is included |
| 429 | rate-limited | See §8 |
| 500 | internal-error | Unexpected; includes requestId only |
| 503 | maintenance, dependency-unavailable | Planned 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 anyPOSTthat creates something or changes state. It is required on: punches, leave and attendance requests, claim submission, all/pay/runsactions,/ntf/tasks/:id/*decisions, imports, file registration, public APIPOSTs 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-flightwithRetry-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).
| Bucket | Default limit | Burst |
|---|---|---|
| Per user, internal API | 300 requests/min | 60 |
| Per tenant, internal API (all users) | 3,000 requests/min per 100 active employees, minimum 3,000 | 500 |
| Per public API client | 600 requests/min | 100 |
| Login and password reset, per IP and per identifier | 10/min | 5 |
| Punch endpoints, per employee | 10/min | 5 |
| Exports and async jobs, per tenant | 20 concurrent jobs | — |
| Ingest, per device | 60 batches/min | 20 |
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
| Surface | Policy |
|---|---|
Internal /api/v1 | Deployed 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 /v1 | Additive only: new endpoints, new optional fields, new enum values (clients must tolerate unknown values). Removing or renaming anything needs /v2. |
| Deprecation | Announced 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 webhooks | Payload schemas are versioned (schemaVersion); a breaking change emits both versions during the deprecation window. See Events & webhooks. |
| Mobile | The 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" } }
| Rule | Detail |
|---|---|
| Start | POST returns 202 with a Location header and a job body |
| Poll | GET the job: QUEUED → RUNNING (with progressPct) → SUCCEEDED / FAILED / CANCELLED |
| Result | result (counts, warnings) and resultFileId for downloads; failures carry a problem+json error |
| Push instead of poll | The in-app notification (rpt.export.ready) and, for public API clients, webhooks |
| Cancel | POST /…/:id/cancel where the job supports it |
| Generic jobs | Imports, recomputes and other long tasks use GET /api/v1/sys/jobs/:id |
| Bulk writes | Synchronous 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 runs | Imports 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.jsonandopenapi.public.json. The public document includes only endpoints markedpublic, 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
/v1fails 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
| Topic | Rule |
|---|---|
| CORS | The internal API allows only the tenant's own origins (subdomain and verified custom domain). The public API allows no browser origins by default. |
| Caching | GET responses carry ETag and Cache-Control: private, no-cache; payslips and other sensitive downloads use no-store. |
| Compression | gzip and br |
| Timeouts | 30 s at the load balancer; synchronous handlers are budgeted at 2 s |
| Security headers | HSTS, CSP (web), X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer |
| Health | GET /healthz (liveness) and GET /readyz (dependencies) on each process; not tenant-scoped |