Node SDK contract
Scope: the API-key contract @optomatica/optolink-sdk wraps — link CRUD + QR only.
No deferred-matching surface exists on this SDK; the CLIENT-tier mobile surface is
Android / iOS.
Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (
src/link/link.controller.ts,link.service.ts,short-code.service.ts,qr-code.service.ts,src/auth/guards/api-key-auth.guard.ts,src/common/filters/http-exception.filter.ts,src/rate-limiting/rate-limit.decorator.ts) and optolink-node @ d4b5dae (src/index.ts,links.ts,errors.ts,request.ts).
Identity
| npm | @optomatica/optolink-sdk 0.1.0 (published) |
| Runtime | Node ≥ 18, zero runtime dependencies, dual ESM + CJS |
| Repo | optolink-node/ (own package root) |
| Compat line | "Compatible with OptoLink backend v2.1.0+" (README) |
The backend has no API versioning — the compat number is a hand-maintained
platform-release line in each SDK README; backend package.json and the OpenAPI
document both say 1.0. The number matches the public docs (docs/sdk/overview.md);
there is no machine-readable backend version to check it against.
Auth
Authorization: Bearer opl_api_… on every request — all endpoints are SERVER
tier (@RequireApiKeyTier(SERVER)); a CLIENT key (opl_sdk_…) authenticates
but gets 403 API key tier 'CLIENT' is not permitted on this endpoint (requires 'SERVER'). No global path prefix. 401 messages: Missing Authorization header / Invalid Authorization format / Invalid API key format (short) /
Invalid API key (unknown) / Organization is suspended.
Endpoints (7 methods)
| Method | HTTP | Returns (include matrix, verified) |
|---|---|---|
create | POST /links | 201 full row + url — no domain/template/clickCount |
get | GET /links/:id | full row + url + domain {domain,status} + full template row (null when unlinked) |
list | GET /links | {data, total, page, limit} — items add url, domain, clickCount; no template |
update | PATCH /links/:id | raw row only — no url/domain/template |
delete | DELETE /links/:id | 200 {deleted: true} |
qrSvg / qrPng | GET /links/:id/qr.svg / qr.png | SVG markup / PNG bytes, 404 if link missing |
The POST/PATCH asymmetry is why the SDK auto-refetches (GET /links/:id
after both, one shared retry budget) so every method resolves the uniform full
Link. :id is UUID-piped — a non-UUID is 400 Validation failed (uuid is expected) with no details[], not 404.
Create gates (spec delta): POST /links mirrors the portal's create gates —
428 APP_CONFIG_REQUIRED before the quota check, then 402 QUOTA_EXCEEDED
(bodies + semantics: Entitlements enforcement).
The hand-off spec predates these gates and lists neither.
Update semantics: undefined key = keep, JSON null = clear-to-inherit/empty —
the SDK passes null through verbatim. Create's domainId is UUID-validated;
update's is a MaxLength(100) string (deliberate asymmetry, don't "fix").
Duplicate shortCode is a clean 409 Conflict — Short code "X" is already in use in this organization (ShortCodeService.validate, pre-write check). Spec
delta: the spec describes a Prisma 500 here; that path no longer exists.
Error envelope (all non-2xx, GlobalExceptionFilter)
{"statusCode":404,"error":"Not Found","message":"Link <id> not found",
"details":[{"message":"…"}],"timestamp":"…","path":"/links/<id>","requestId":"…"}
details[]only on 400 validation (message text; no field names today);erroris recomputed from a status→name table — 409 is missing from the table, so the conflict above renderserror: "Error"(cosmetic wart, stable).- The three entitlement exceptions forward extra fields (
code,quota/feature) on top of the envelope — shapes in Entitlements enforcement. requestIdis echoed only when the client sentx-request-id; every response also carries anX-Request-Idheader (middleware generates one when the client didn't).- 429 body:
Rate limit exceeded. Retry after N second(s).— noRetry-Afterheader anywhere; the SDK regexes N out of the message. - Non-JSON error bodies (proxy HTML) still map to
OptoLinkErrorwith a generic message and the body text inraw; transport failures →OptoLinkConnectionError.
Pagination
page (default 1), limit (default 20, max 100). Envelope is exactly
{data, total, page, limit} — no hasNext/totalPages; listAll() walks while
page * limit < total. Unknown query/body keys are rejected 400
(forbidNonWhitelisted). isActive serializes as literal "true"/"false" —
the server Transform maps any other present value to false, so false must
be sent, never omitted.
QR
qr.svg → image/svg+xml, qr.png → image/png; fixed shape (256×256, margin
2, #000000 on #ffffff) — no query params or options exist; payload encodes
https://{domain}/{orgKey}/{shortCode}. Cache-Control: public, max-age=86400.
Rate limits
Keyed per API key (IP only when unauthenticated). POST/PATCH/DELETE run the
creation preset (100/s), GETs and QR the resolution preset (1000/s) —
generous; the limits that actually bite live on the mobile surface
(Android · iOS).
Source-checked against optolink-backend @ 29f8589, 2026-10-07. The
error: "Error"409 rendering is code-read only — no live curl pass ran for this harvest (B-018); probe on next live use.
Retries (SDK-side): reads retry connection/429/5xx; writes never retry 5xx (no
idempotency keys — a landed retried POST is a second link); 429 sleeps the
regexed retryAfterSeconds. Defaults: 10 s timeout per attempt, 2 retries.