Alerts
An alert is the user-facing record produced when a scan run finds a hit against one of your watches. Each alert wraps exactly one Hit, the per-source verbatim record (denormalised envelope + raw payload).
Alerts are never created via the API — scan jobs running on the server are the only producer. Clients can list and read alerts. Mutating alert state (mark-read, dismiss) is in-app dashboard only in MVP; the public REST surface is read-only.
The Alert resource
{ "id": "ckalert0000000xyz", "watchId": "ckxyz0000000abcdef", "state": "unread", "severity": null, "rationale": null, "rationaleAt": null, "emailedAt": "2026-05-02T11:01:33.000Z", "createdAt": "2026-05-02T11:00:00.000Z", "hit": { "id": "ckhit00000000aaa", "entityType": "trademark", "sourceCode": "global_trademark_database", "externalId": "ST13-1500000000000", "displayName": "ACME COSMETICS", "ownerName": "Globex SA", "ownerCountryCode": "FR", "jurisdiction": "EM", "filedAt": "2026-04-12T00:00:00.000Z", "registeredAt": null, "expiresAt": null, "status": "filed", "classes": [3, 5], "rawPayload": { "_source": "redacted", "marks": [{ "st13": "1500000000000", "indexationLanguage": "en" }], "applicants": [{ "name": "Globex SA", "address": { "country": "FR" } }] } }}Alert fields
| Field | Type | Notes |
|---|---|---|
id | string | Server-assigned cuid(). |
watchId | string | The watch this alert was raised against. |
state | "unread" | "read" | "dismissed" | Drives the in-app unread badge. Mutable via PATCH. |
severity | "low" | "medium" | "high" | null | AI-scored. Always null in MVP (plan §6.1 defers AI scoring to v2). |
rationale | string | null | Markdown explanation, populated by the v2 scorer. Always null in MVP. |
rationaleAt | string | null | When the rationale was produced. |
emailedAt | string | null | When the alert email was sent (Solo+ tiers); null for in-app-only deliveries. |
createdAt | string | ISO-8601. |
Hit fields (alert.hit)
The hit object is denormalised onto every alert response so clients do not have to fan out a second request. It carries:
| Field | Type | Notes |
|---|---|---|
id | string | Server-assigned. |
entityType | WatchType | Mirrors the watch’s type in MVP; kept independent so a single watch can fan out into multiple entity types in v2. |
sourceCode | SourceCode | Which adapter produced the hit (global_trademark_database in MVP). |
externalId | string | Source-native primary key (e.g. an st13 identifier from the global trademark database). Stable across rescans of the same record. |
displayName, ownerName, ownerCountryCode, jurisdiction, filedAt, registeredAt, expiresAt, status, classes | various | The common envelope — denormalised from each adapter’s response so dashboards and diff jobs can query uniformly across sources. |
rawPayload | object | Verbatim source response (Prisma JSONB column). Forensic / legal use; the schema is owned by the source adapter and is not part of the API contract. Treat it as opaque. |
A hit is unique per (watchId, sourceCode, externalId) — the same upstream record can legitimately appear under multiple watches but only once per watch+source.
GET /api/v1/alerts
List alerts for the calling user.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit | integer | 50 | Max 200. |
cursor | string | — | Opaque cursor from a previous response. |
state | "unread" | "read" | "dismissed" | — | Filter by state. Repeat the param to OR multiple states. |
watchId | string | — | Restrict to a single watch. |
since | string | — | ISO-8601 lower bound on createdAt (inclusive). Useful for incremental polling. |
Required scope: alerts:read.
Example request
curl -sS "https://api.trademarksentinel.app/api/v1/alerts?state=unread&limit=2" \ -H "Authorization: Bearer ts_REPLACE_ME"Example response — 200 OK
{ "data": [ { "id": "ckalert0000000xyz", "watchId": "ckxyz0000000abcdef", "state": "unread", "severity": null, "rationale": null, "rationaleAt": null, "emailedAt": "2026-05-02T11:01:33.000Z", "createdAt": "2026-05-02T11:00:00.000Z", "hit": { "id": "ckhit00000000aaa", "entityType": "trademark", "sourceCode": "global_trademark_database", "externalId": "ST13-1500000000000", "displayName": "ACME COSMETICS", "ownerName": "Globex SA", "ownerCountryCode": "FR", "jurisdiction": "EM", "filedAt": "2026-04-12T00:00:00.000Z", "registeredAt": null, "expiresAt": null, "status": "filed", "classes": [3, 5], "rawPayload": { "_source": "redacted" } } } ], "pagination": { "nextCursor": null, "limit": 2 }}Status codes
| Code | When |
|---|---|
200 | Success. |
400 | Malformed query (e.g. since not an ISO-8601 timestamp). |
401 | Missing or invalid API key. |
403 | Tier below Team, or scope alerts:read missing. |
429 | Rate limit exceeded. |
GET /api/v1/alerts/{id}
Read a single alert. The response shape is identical to a list-row, including the embedded hit.
Required scope: alerts:read.
Example request
curl -sS https://api.trademarksentinel.app/api/v1/alerts/ckalert0000000xyz \ -H "Authorization: Bearer ts_REPLACE_ME"Example response — 200 OK
{ "data": { "id": "ckalert0000000xyz", "watchId": "ckxyz0000000abcdef", "state": "unread", "severity": null, "rationale": null, "rationaleAt": null, "emailedAt": "2026-05-02T11:01:33.000Z", "createdAt": "2026-05-02T11:00:00.000Z", "hit": { "id": "ckhit00000000aaa", "entityType": "trademark", "sourceCode": "global_trademark_database", "externalId": "ST13-1500000000000", "displayName": "ACME COSMETICS", "ownerName": "Globex SA", "ownerCountryCode": "FR", "jurisdiction": "EM", "filedAt": "2026-04-12T00:00:00.000Z", "registeredAt": null, "expiresAt": null, "status": "filed", "classes": [3, 5], "rawPayload": { "_source": "redacted", "marks": [{ "st13": "1500000000000", "indexationLanguage": "en" }] } } }}Status codes
| Code | When |
|---|---|
200 | Success. |
401 | Missing or invalid API key. |
403 | Tier below Team, or scope alerts:read missing. |
404 | The alert does not exist or is not visible to the calling key. |
429 | Rate limit exceeded. |
PATCH /api/v1/alerts/{id}
Change the alert state. No other field is mutable — to “edit” an alert, dismiss it and let the next scan re-create it.
Required scope: alerts:read. (Yes, alerts:read. There is no broader alert-write surface in MVP, and gating state changes on a separate scope would force every reader to ask for write privileges they do not need. See authentication — scopes.)
Request body
{ "state": "read" }| Field | Required | Notes |
|---|---|---|
state | yes | One of "unread", "read", "dismissed". Any other value returns 422. |
Sending any field other than state returns 422 validation_failed with error.details.field naming the offending key.
Example request
curl -sS -X PATCH https://api.trademarksentinel.app/api/v1/alerts/ckalert0000000xyz \ -H "Authorization: Bearer ts_REPLACE_ME" \ -H "Content-Type: application/json" \ -d '{ "state": "dismissed" }'Example response — 200 OK
{ "data": { "id": "ckalert0000000xyz", "watchId": "ckxyz0000000abcdef", "state": "dismissed", "severity": null, "rationale": null, "rationaleAt": null, "emailedAt": "2026-05-02T11:01:33.000Z", "createdAt": "2026-05-02T11:00:00.000Z", "hit": { "id": "ckhit00000000aaa", "entityType": "trademark", "sourceCode": "global_trademark_database", "externalId": "ST13-1500000000000", "displayName": "ACME COSMETICS", "ownerName": "Globex SA", "ownerCountryCode": "FR", "jurisdiction": "EM", "filedAt": "2026-04-12T00:00:00.000Z", "registeredAt": null, "expiresAt": null, "status": "filed", "classes": [3, 5], "rawPayload": { "_source": "redacted" } } }}Status codes
| Code | When |
|---|---|
200 | State updated (or unchanged — idempotent). |
400 | Malformed JSON. |
401 | Missing or invalid API key. |
403 | Tier below Team, or scope alerts:read missing. |
404 | The alert does not exist or is not visible to the calling key. |
422 | Validation failed (unknown state value, or any other field present in the body). |
429 | Rate limit exceeded. |