API_CONTRACTS — lock-integration-service
Bundle: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · EVENT_SCHEMAS · DATA_MODEL
Cross-cutting API conventions: docs/05 — API Design. Errors: docs/standards/ERROR_CODES — LOCK.
All endpoints under /api/v1/.... JSON over HTTPS, UTF-8. Auth: OAuth2 Bearer (Authorization: Bearer <jwt>) issued by iam-service; tenant resolved from X-Tenant-Id header (mandatory for all non-webhook routes); device binding asserted via X-Device-Id for desktop callers. Idempotency via Idempotency-Key header on POST/PATCH/DELETE that mutate state. If-Match header used for optimistic concurrency on PATCH /key-credentials/:id.
1. KeyCredentials
1.1 POST /api/v1/key-credentials — Issue a credential
Manual issuance path. Saga-driven issuance does not call this endpoint; the saga drives the use case directly. This endpoint exists for staff-initiated manual issuance and for the lost-key flow.
Required role: lock.key.issue (front-desk, GM, on-call).
Request:
POST /api/v1/key-credentials HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01HX...
X-Device-Id: dev_01HX...
Idempotency-Key: 9c1f-...-staff-manual-rsv_01HX-1
Content-Type: application/json
{
"reservationId": "rsv_01HX...",
"rooms": ["rmu_01HX..."],
"validFrom": "2026-05-01T14:00:00Z",
"validUntil": "2026-05-03T11:00:00Z",
"preferredKinds": ["mobile_app", "pin_code", "rfid_card"],
"guest": {
"id": "gst_01HX...",
"displayName": "Karimi, Ahmad",
"contact": { "phoneE164": "+93701234567" }
},
"scope": { "areas": ["lobby", "gym"] },
"channelHint": "desk"
}
Response 201 Created:
HTTP/1.1 201 Created
Location: /api/v1/key-credentials/key_01HX...
Content-Type: application/json
{
"id": "key_01HX...",
"tenantId": "tnt_01HX...",
"propertyId": "ppt_01HX...",
"reservationId": "rsv_01HX...",
"kind": "mobile_app",
"rooms": ["rmu_01HX..."],
"scope": { "areas": ["lobby", "gym"] },
"validFrom": "2026-05-01T14:00:00Z",
"validUntil": "2026-05-03T11:00:00Z",
"state": "active",
"vendor": "ttlock",
"provisional": false,
"delivery": {
"artifact": { "type": "mobile_token", "opaqueRef": "tk_01HX..." },
"deepLink": "melmastoon://tenant/silk/keys/key_01HX..."
},
"warnings": [],
"issuedAt": "2026-04-30T10:13:42Z",
"version": 2
}
vendorRef is never present in the response.
Errors: MELMASTOON.LOCK.VENDOR_UNREACHABLE (502), MELMASTOON.LOCK.KEY_ISSUE_FAILED (502), MELMASTOON.LOCK.DEVICE_NOT_PAIRED (409), MELMASTOON.LOCK.CARD_ENCODER_OFFLINE (503), MELMASTOON.GENERAL.VALIDATION_FAILED (422), MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE (422), MELMASTOON.IDENTITY.PERMISSION_DENIED (403), MELMASTOON.RESERVATION.INVALID_STATE_TRANSITION (409 — reservation not confirmed).
1.2 GET /api/v1/key-credentials/:id
Required role: lock.key.read.
GET /api/v1/key-credentials/key_01HX... HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01HX...
200 OK
{ /* same shape as 1.1 response, without `delivery.artifact` if state ≠ 'active' */ }
404 MELMASTOON.GENERAL.RESOURCE_NOT_FOUND for unknown id or cross-tenant access (never reveals existence across tenants).
1.3 GET /api/v1/key-credentials
List with filters: ?reservationId=, ?guestId=, ?propertyId=, ?state=active,pending, ?holderKind=guest|staff_master, ?validAt=<ISO>, ?cursor=, ?limit= (default 50, max 200).
200 OK
{
"items": [ { ... } ],
"page": { "nextCursor": "eyJsYXN0SWQ...", "limit": 50 }
}
1.4 PATCH /api/v1/key-credentials/:id
Update validUntil, rooms, or scope. Optimistic concurrency via If-Match: <version>.
PATCH /api/v1/key-credentials/key_01HX... HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01HX...
If-Match: 2
Idempotency-Key: 9c1f-...-extend-rsv_01HX-1
Content-Type: application/merge-patch+json
{ "validUntil": "2026-05-04T11:00:00Z" }
200 OK returns the new representation with incremented version. 412 MELMASTOON.GENERAL.PRECONDITION_FAILED on version mismatch.
1.5 POST /api/v1/key-credentials/:id/revoke
POST /api/v1/key-credentials/key_01HX.../revoke HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01HX...
Idempotency-Key: 9c1f-...-revoke-rsv_01HX-1
Content-Type: application/json
{ "reason": "lost" }
reason ∈ {checkout, cancellation, security, lost, replaced}. Idempotent — second call returns 200 OK with the existing revoked credential.
200 OK { id, state: "revoked", revokedAt, revokeReason }.
1.6 POST /api/v1/key-credentials/:id/suspend / /unsuspend
POST /api/v1/key-credentials/key_01HX.../suspend HTTP/1.1
Idempotency-Key: ...
{ "reason": "no_show" }
200 OK { id, state: "suspended", suspendedAt, suspendReason }
unsuspend requires no body; only valid from state suspended.
1.7 POST /api/v1/key-credentials/:id/replace — Lost-key flow
POST /api/v1/key-credentials/key_01HX.../replace HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01HX...
Idempotency-Key: 9c1f-...-replace-rsv_01HX-1
Content-Type: application/json
{
"reason": "lost",
"newKindHint": "rfid_card" // optional; defaults to original kind
}
Behavior: atomic revoke of :id + issue of new credential bound to the same reservation; both events emitted in the same outbox flush.
201 Created
Location: /api/v1/key-credentials/key_01HY...
{
"revoked": { "id": "key_01HX...", "state": "revoked", "revokedAt": "..." },
"issued": { "id": "key_01HY...", "state": "active", /* full credential */ }
}
1.8 GET /api/v1/key-credentials/:id/audit
Returns the full KeyCredentialAttempt[] and the lifecycle event timeline for the credential. Required role: lock.audit.read.
200 OK
{
"credential": { "id": "key_01HX...", "state": "revoked", ... },
"lifecycle": [
{ "event": "requested", "at": "2026-04-30T10:13:40Z", "actor": "saga:issue" },
{ "event": "issued", "at": "2026-04-30T10:13:42Z", "vendor": "ttlock" },
{ "event": "revoked", "at": "2026-05-03T11:00:01Z", "reason": "checkout" }
],
"attempts": [
{ "id": "kca_01...", "deviceId": "lck_01...", "outcome": "granted",
"attemptedAt": "2026-05-01T14:32:11Z" },
{ "id": "kca_02...", "deviceId": "lck_01...", "outcome": "denied",
"denyReason": "expired", "attemptedAt": "2026-05-03T11:42:00Z" }
]
}
2. Lock devices
2.1 POST /api/v1/lock-devices — Register a device
Called by the desktop pairing wizard once the device has been physically paired with the vendor.
POST /api/v1/lock-devices HTTP/1.1
Idempotency-Key: pair-lck-...
{
"propertyId": "ppt_01HX...",
"vendor": "ttlock",
"vendorDeviceRef": "<vendor-opaque>",
"label": "Room 204 main door",
"rooms": ["rmu_01HX..."],
"capabilities": {
"mobileKey": true, "cardEncoding": false, "pin": true, "qr": false,
"nfc": false, "remoteRevoke": true, "remoteIssue": true,
"offlineIssuance": true, "scopeFloors": false, "scopeAreas": false
},
"firmware": "8.4.7"
}
201 Created with Location: /api/v1/lock-devices/lck_01HX....
2.2 GET /api/v1/lock-devices?propertyId=...
List devices with online/battery snapshot.
2.3 GET /api/v1/lock-devices/:id/health
200 OK
{
"deviceId": "lck_01HX...",
"online": true,
"battery": { "percent": 64 },
"clockSkewMs": -1240,
"lastSyncAt": "2026-04-30T09:40:00Z",
"warnings": []
}
2.4 POST /api/v1/lock-devices/:id/decommission
Decommissions a device. Triggers vendor credential rotation within 24h. Required role: lock.device.admin.
3. Master keys
3.1 POST /api/v1/master-keys — Issue a staff master
POST /api/v1/master-keys HTTP/1.1
Idempotency-Key: shf-master-...
{
"staffUserId": "usr_01HX...",
"propertyId": "ppt_01HX...",
"scope": { "kind": "floor", "floor": "3" },
"validFrom": "2026-05-01T06:00:00Z",
"validUntil": "2026-05-01T18:00:00Z",
"kind": "rfid_card"
}
201 Created { id: "mky_01...", keyCredentialId: "key_01...", state: "active", ... }.
3.2 POST /api/v1/master-keys/:id/revoke
{ "reason": "checkout" } // typical: shift end
3.3 GET /api/v1/master-keys?staffUserId=&propertyId=&active=true
4. Vendor adapters
4.1 GET /api/v1/vendor-adapters?propertyId=...
Returns each (tenant, property, vendor) adapter row with current health snapshot.
200 OK
{
"items": [
{
"id": "vad_01HX...",
"vendor": "ttlock",
"environment": "production",
"enabled": true,
"precedence": 10,
"capabilities": { ... },
"health": {
"circuit": "closed",
"errorRatePct": 0.4,
"p95LatencyMs": 980,
"p99LatencyMs": 1620,
"lastTrippedAt": null
}
}
]
}
4.2 POST /api/v1/vendor-adapters/:id/health-check
Forces a synchronous probe (cheap read against vendor). Returns the same shape as health above. Required role: lock.adapter.admin.
5. Offline issuance certificates
5.1 POST /api/v1/offline-issuance/certificates
Called by the desktop pairing wizard. The desktop generates the Ed25519 keypair locally; the public key arrives in the request; the cloud signs and returns a certificate envelope.
POST /api/v1/offline-issuance/certificates HTTP/1.1
X-Device-Id: dev_01HX...
Idempotency-Key: pair-cert-dev_01HX...
{
"propertyId": "ppt_01HX...",
"publicKeyEd25519": "MCowBQYDK2Vw...",
"allowedKinds": ["rfid_card", "pin_code"],
"maxValidWindowHours": 48
}
201 Created
{
"id": "oki_01HX...",
"serial": "lock-oic-2026-04-30-01HX...",
"tenantId": "tnt_01HX...",
"propertyId": "ppt_01HX...",
"allowedKinds": ["rfid_card", "pin_code"],
"maxValidWindowHours": 48,
"issuedAt": "2026-04-30T10:00:00Z",
"expiresAt": "2026-07-29T10:00:00Z",
"certificate": "<base64 signed envelope: tenantId, propertyId, allowedKinds, maxWindow, serial, expiresAt, issuerSig>"
}
5.2 GET /api/v1/offline-issuance/certificates/revoked
Returns the revocation list (CRL). Polled by desktops every 6 h while online.
200 OK
{ "revoked": [ { "serial": "lock-oic-...", "revokedAt": "...", "reason": "rotation" }, ... ] }
5.3 POST /api/v1/offline-issuance/certificates/:id/revoke
Required role: lock.cert.admin. 200 OK.
6. Vendor webhook receivers
Each vendor has a dedicated subdomain-routed receiver. Path: /webhooks/v1/<vendor>. Auth: HMAC signature in vendor-specific header (X-Ttlock-Signature, X-Salto-Signature, X-Vostio-Signature); mTLS where vendor supports.
6.1 POST /webhooks/v1/ttlock
POST /webhooks/v1/ttlock HTTP/1.1
X-Ttlock-Signature: <hex hmac-sha256>
X-Ttlock-Timestamp: 1714478000
Content-Type: application/json
{ "eventType": "access.granted", "lockId": "...", "ekeyId": "...", "ts": 1714478000, ... }
202 Accepted always (after signature + dedupe). Errors:
401 MELMASTOON.LOCK.WEBHOOK_SIGNATURE_INVALID— signature mismatch.200 OK— duplicate event (idempotent acknowledgement to keep vendor from retrying).
6.2 POST /webhooks/v1/salto and POST /webhooks/v1/vostio — same shape, vendor-specific signature header and payload.
7. Internal sync endpoints
The desktop's offline-issued credentials and pulled active credentials traverse /sync/v1/push and /sync/v1/pull (owned by sync-service, but the handlers for the lock.* aggregate are registered by this service). See SYNC_CONTRACT for the per-aggregate conflict policy and the push/pull payload shape.
8. Error envelope (all endpoints)
Every error follows the canonical envelope (ERROR_CODES):
{
"error": {
"type": "https://errors.melmastoon.ghasi.io/lock/key-issue-failed",
"code": "MELMASTOON.LOCK.KEY_ISSUE_FAILED",
"title": "Vendor refused to issue the key",
"status": 502,
"detail": "TTLock API returned 'eKey already exists for this lock' after 3 retries.",
"instance": "/api/v1/key-credentials",
"errors": [{ "field": "preferredKinds", "code": "kind_unsupported" }],
"traceId": "00-...-01",
"requestId": "req_01HX...",
"tenantId": "tnt_01HX...",
"retriable": true,
"retryAfter": 30,
"userMessageKey": "errors.lock.key_issue_failed",
"docUrl": "https://docs.melmastoon.ghasi.io/errors/lock/key-issue-failed",
"runbook": "https://runbooks.melmastoon.ghasi.io/lock/key-issue"
}
}
9. Pagination, filtering, sorting, idempotency
- All list endpoints use cursor pagination with
?cursor=&limit=(max 200; default 50). ?sort=accepts a comma list of[-]field(default sort:-updatedAt).- Filters listed under each endpoint are the only supported filters; unknown filters return
422 MELMASTOON.GENERAL.VALIDATION_FAILEDwith the offending key inerrors[]. - All POST/PATCH/DELETE that mutate state require an
Idempotency-Key(UUID v4 or ULID); the same key with a different body returns409 MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED. Replays with the same body return the original response.
10. Headers (always present)
| Header | Direction | Purpose |
|---|---|---|
X-Tenant-Id | request | mandatory; tenant scoping |
X-Device-Id | request | desktop callers; binds to a paired device |
X-Request-Id | request | client-supplied request id (ULID); echoed in response and logs |
X-Trace-Id | response | W3C trace id for cross-service correlation |
Idempotency-Key | request | mutating endpoints |
If-Match, If-None-Match | request | optimistic concurrency on PATCH |
Retry-After | response | on 429 / 502 / 503 / 504 |
Cache-Control: no-store | response | always; lock data is sensitive |
11. Rate limits
| Route family | Limit | Burst |
|---|---|---|
POST /api/v1/key-credentials* | 60 / min / property | 30 |
GET /api/v1/key-credentials* | 600 / min / tenant | 200 |
POST /api/v1/master-keys* | 30 / min / property | 15 |
POST /webhooks/v1/* | 5000 / min / vendor (per source IP) | 1000 |
429 MELMASTOON.GENERAL.RATE_LIMITED with Retry-After on breach.
12. OpenAPI
The full OpenAPI 3.1 spec is generated from controllers and committed to services/lock-integration-service/openapi.json in the application monorepo. CI gates breaking changes via the OpenAPI diff per 05 §3.