Errors
Every non-2xx response from /api/v1 returns a uniform JSON envelope so client error handling can be written once.
Envelope
{ "error": { "code": "validation_error", "message": "Invalid watch payload.", "details": { "formErrors": [], "fieldErrors": { "scanCadence": [ "Invalid enum value. Expected 'hourly' | 'daily' | 'weekly'." ] } } }}| Field | Type | Notes |
|---|---|---|
error.code | string | Machine-readable identifier. The full list is below. New codes may be added; clients must treat unknown codes as the generic internal failure. |
error.message | string | Human-readable description. May change between releases — do not match on this. |
error.details | unknown | undefined | Optional, code-specific structured detail. For validation_error: zod’s flatten() shape ({ formErrors, fieldErrors }). Otherwise treat as opaque. |
There is no top-level data key on error responses, and there is no error key on success responses. Branch on the HTTP status code (or, equivalently, on "error" in body).
Standard codes
| Code | HTTP | When |
|---|---|---|
unauthenticated | 401 | Missing Authorization header, malformed Bearer token, unknown key, or revoked key. |
forbidden | 402 | Tier-quota breach: maxWatches reached, scanCadence faster than tier minimum. Body includes error.upgradeUrl: "/pricing". (Status is 402 Payment Required, not 403, because the fix is “upgrade”, not “fix the request”.) |
forbidden | 403 | Tier below Team (no apiAccess), or required scope missing on the key. |
not_found | 404 | The path does not match a route, or the resource does not exist / is not visible to the calling key. We deliberately do not distinguish “you cannot see it” from “it does not exist” to avoid leaking ID space. |
validation_error | 400 | Malformed JSON. Distinct from 422 to mirror Express’s body parser. |
validation_error | 422 | Body or query parameters parsed but failed schema validation. details carries the zod flatten() shape ({ formErrors, fieldErrors }). |
method_not_allowed | 405 | The endpoint does not accept the HTTP method used. Includes Allow response header. |
rate_limited | 429 | Per-key quota exceeded. See rate limits for the response headers. |
internal | 500 | Unhandled server error. Retryable (idempotently). The dashboard’s status page is the canonical place to check for incidents. |
validation_error — details shape
{ "error": { "code": "validation_error", "message": "Invalid watch payload.", "details": { "formErrors": [], "fieldErrors": { "scanCadence": [ "Invalid enum value. Expected 'hourly' | 'daily' | 'weekly'." ] } } }}details is the result of zod’s flatten() — formErrors carries top-level (cross-field) issues, fieldErrors is a map from dotted path to a list of human-readable error messages. Consume fieldErrors for per-field UI; consume formErrors for the “everything else” bucket.
forbidden — distinguishing causes
forbidden covers two distinct causes — tier-quota (402) and tier/scope (403) — but the error.code is the same string in both. Branch on the HTTP status code to tell them apart, and surface error.message to the operator. Tier-quota responses carry an additional error.upgradeUrl field (always /pricing today).
402—solo plan is capped at 25 active watches; upgrade to add more.402—solo plan does not allow hourly scans (min cadence: daily).403—free plan does not include REST API access. Upgrade to Team to use /api/v1.403—This API key does not have the required scope: watches:write.
Examples
Missing Bearer token — 401
curl -sS https://api.trademarksentinel.app/api/v1/watches{ "error": { "code": "unauthenticated", "message": "Missing or invalid API key." }}Insufficient scope — 403
curl -sS -X POST https://api.trademarksentinel.app/api/v1/watches \ -H "Authorization: Bearer ts_KEY_WITH_ONLY_WATCHES_READ" \ -H "Content-Type: application/json" \ -d '{ "watchType": "trademark", "name": "Acme", "attributes": {} }'{ "error": { "code": "forbidden", "message": "This API key does not have the required scope: watches:write." }}Validation failed — 422
curl -sS -X POST https://api.trademarksentinel.app/api/v1/watches \ -H "Authorization: Bearer ts_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "watchType": "trademark", "name": "Acme", "attributes": {}, "scanCadence": "minutely" }'{ "error": { "code": "validation_error", "message": "Invalid watch payload.", "details": { "formErrors": [], "fieldErrors": { "attributes.niceClasses": ["Required"], "attributes.designations": ["Required"], "scanCadence": [ "Invalid enum value. Expected 'hourly' | 'daily' | 'weekly'." ] } } }}