Alerts
A review or block decision puts the event on an alert. Alerts are grouped by source: while a source has an open or in_review alert, its later review and block decisions join that alert instead of raising new ones. occurrences counts them. Once an alert is pending closure or closed, the next decision for that source starts a new alert.
An alert starts at medium priority for a review and high for a block. A block that joins a medium alert raises it to high. decision_id and event_id point at the highest-scoring decision on the alert.
Decisions for events without a source.entity_id can't be grouped, so each gets its own alert.
A screening with a potential match raises an alert too. See Screening alerts.
Statuses
| Status | Meaning | How it gets there |
|---|---|---|
open | Waiting for an analyst | New alert, or set with PATCH |
in_review | Being worked | Set with PATCH, or a rejected closure |
pending_closure | Someone proposed a disposition and it's waiting for a second person | POST .../closure |
closed | A second person approved the closure | POST .../closure/approve |
Alerts that are pending_closure or closed can't be edited with PATCH. Every change is written to the audit log with the key that made it.
List alerts
GET /v1/alertsAlerts a page at a time. Each carries the decision and event behind it, so a queue renders from one request. With queue=open they come soonest deadline (due_at) first; otherwise most recently changed first. Pass limit (1 to 200, default 50); if there are more, the response has a next_cursor to send back as cursor.
Query parameters
open (open and in review), pending_closure or closed.open, in_review, pending_closure or closed. Ignored when queue is set.review or block. Screening alerts count as review.ALR-20481.Counts
GET /v1/alerts/countsHow many alerts are in each queue, counted on the server, so tab counts are right however many alerts there are. Pass assigned_to to fill mine.
{
"open": 38,
"in_review": 7,
"pending_closure": 2,
"closed": 1204,
"mine": 5,
"overdue": 2,
"near_sla": 3,
"median_close_minutes": 252
}
open includes those in review. overdue and near_sla are open alerts past their due_at or due within the hour. median_close_minutes covers alerts closed in the last 7 days and is absent when none were.
Get an alert
GET /v1/alerts/{id}One alert, with the same fields as the list. An unknown id is a 404 with alert not found.
Example
curl "$SENTINEL_URL/v1/alerts?status=open" \
-H "Authorization: Bearer $SENTINEL_KEY"
{
"alerts": [
{
"id": "c7d4e1a2-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"number": 20481,
"decision_id": "a81e4f02-6c9d-4b7a-9e3f-2d1c0b9a8f7e",
"event_id": "c4d5e6f7-0a1b-4c2d-8e9f-3a4b5c6d7e8f",
"status": "open",
"priority": "medium",
"occurrences": 3,
"last_event_at": "2026-10-15T11:21:40Z",
"due_at": "2026-10-16T11:09:12.318Z",
"created_at": "2026-10-15T11:09:12.318Z",
"updated_at": "2026-10-15T11:21:40.552Z",
"decision": "review",
"risk_score": 60,
"decided_in_ms": 38,
"reasons": [
{
"rule": "structuring_below_threshold",
"version": 1,
"detail": "repeated transfers just below the reporting threshold within 24h",
"weight": 60
}
],
"source": {
"id": "cus_10442",
"kind": "customer",
"name": "Ama Mensah"
},
"event": {
"type": "transfer.outbound",
"occurred_at": "2026-10-15T11:09:12Z",
"amount": {
"value": 4900,
"currency": "USD"
},
"rail": "mobile_money",
"source_id": "cus_10442"
}
}
]
}
Update an alert
PATCH /v1/alerts/{id}Changes an alert's status, its assignee, or both. Status can only move between open and in_review here. Closing goes through closure.
Path parameters
Request body
Send either field, or both.
open or in_review.Example
curl -X PATCH "$SENTINEL_URL/v1/alerts/c7d4e1a2-5b6c-4d7e-8f9a-0b1c2d3e4f5a" \
-H "Authorization: Bearer $SENTINEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "in_review",
"assigned_to": "analyst@example.com"
}'
{
"id": "c7d4e1a2-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"decision_id": "a81e4f02-6c9d-4b7a-9e3f-2d1c0b9a8f7e",
"event_id": "c4d5e6f7-0a1b-4c2d-8e9f-3a4b5c6d7e8f",
"status": "in_review",
"priority": "medium",
"assigned_to": "analyst@example.com",
"occurrences": 3,
"last_event_at": "2026-10-15T11:21:40Z",
"created_at": "2026-10-15T11:09:12.318Z",
"updated_at": "2026-10-15T11:25:02.877Z"
}
Notes
GET /v1/alerts/{id}/notesPOST /v1/alerts/{id}/notesGET returns the alert's notes, oldest first. POST adds one and returns 201. Notes can be added at any status, including after the alert is closed. Each note records the key that wrote it as author.
curl -X POST "$SENTINEL_URL/v1/alerts/c7d4e1a2-5b6c-4d7e-8f9a-0b1c2d3e4f5a/notes" \
-H "Authorization: Bearer $SENTINEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "Customer confirmed these are rent payments split across three days. Lease on file."
}'
{
"id": "0d9c8b7a-6f5e-4d3c-2b1a-0f9e8d7c6b5a",
"alert_id": "c7d4e1a2-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"author": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
"body": "Customer confirmed these are rent payments split across three days. Lease on file.",
"created_at": "2026-10-15T11:31:07.204Z"
}
GET returns { "notes": [...] } with the same fields.
Closing an alert
Closing takes two people. One key proposes a disposition, and a different key approves it.
| Disposition | Meaning |
|---|---|
false_positive | Nothing suspicious |
escalated | Passed on for a deeper investigation |
reported | Reported to your FIU. Sentinel records the disposition but doesn't file anything; reporting isn't built yet. |
Request closure
POST /v1/alerts/{id}/closureMoves an open or in_review alert to pending_closure and records who asked.
false_positive, escalated or reported.curl -X POST "$SENTINEL_URL/v1/alerts/c7d4e1a2-5b6c-4d7e-8f9a-0b1c2d3e4f5a/closure" \
-H "Authorization: Bearer $SENTINEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"disposition": "false_positive"
}'
The response is the alert, now with status set to pending_closure, plus disposition and closure_requested_by.
Approve closure
POST /v1/alerts/{id}/closure/approveCloses a pending_closure alert. It must be called with a different key from the one that requested closure, or it returns 403. The response is the alert with status set to closed and closed_by set. No body is needed.
Reject closure
POST /v1/alerts/{id}/closure/rejectSends a pending_closure alert back to in_review and clears the disposition. Any key can reject, including the one that asked, so it also works as "withdraw my request". No body is needed.
Alert fields
| Field | Type | Meaning |
|---|---|---|
id | string, uuid | The alert's ID |
number | integer | A short reference. The dashboard shows it as ALR-20481. |
decision_id | string, uuid | The highest-scoring decision on the alert. Absent on screening alerts. |
event_id | string, uuid | The event behind that decision. Absent on screening alerts. |
status | string | open, in_review, pending_closure or closed |
priority | string | medium or high |
occurrences | integer | How many review or block decisions are on this alert |
last_event_at | string, date-time | When the latest of those events happened |
due_at | string, date-time | When it should be decided by: created_at plus the SLA for its priority, set when the alert is raised. A block decision that makes it high priority brings it forward. Changing SENTINEL_SLA_HIGH or SENTINEL_SLA_MEDIUM affects new alerts only. See Installation. |
assigned_to | string | Who is working it. Absent until assigned. |
disposition | string | Set once closure is requested |
closure_requested_by | string | The key ID that requested closure |
closed_by | string | The key ID that approved it |
created_at | string, date-time | When it was raised |
updated_at | string, date-time | When it last changed |
The list adds these from the decision and event behind the alert:
| Field | Type | Meaning |
|---|---|---|
decision | string | review or block |
risk_score | integer | 0 to 100 |
decided_in_ms | integer | How long the engine took. Absent on decisions made before this was recorded. |
reasons | array | The rules that fired. Each has rule, version, detail and weight, the points it added. weight is absent on older decisions. |
source | object | The entity the alert is about: id, plus kind and name if the entity was registered with POST /v1/entities and has a name attribute |
event | object | type, occurred_at, amount, rail and source_id |
screening_id | string, uuid | The screening that raised the alert. Only on screening alerts. |
Screening alerts
A screening that comes back potential_match raises an alert in the same transaction, so there's never one without the other. It's high priority if any match is on a sanctions list, and medium otherwise. Its source is the screening's reference.
In the list it reads like any other alert, with these differences:
| Field | On a screening alert |
|---|---|
screening_id | The screening's ID. Fetch it with GET /v1/screenings/{id}. |
decision_id, event_id | Absent. There's no event behind it. |
decision | Always review |
risk_score | The best match's score |
reasons | One per match. rule is screening_ plus the list category, for example screening_sanctions, and detail names the entry, list and score. There's no weight. |
source | id is the screening's reference. With no reference, it's the name that was checked. kind and name come from the registered entity if there is one, otherwise from the screening. |
event | type is screening and occurred_at is when the screening ran. There's no amount or rail. |
{
"id": "e2f1a0b9-8c7d-4e6f-9a5b-4c3d2e1f0a9b",
"number": 20502,
"screening_id": "4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09",
"status": "open",
"priority": "high",
"occurrences": 1,
"last_event_at": "2026-10-16T10:41:27.630Z",
"due_at": "2026-10-16T14:41:27.630Z",
"created_at": "2026-10-16T10:41:27.630Z",
"updated_at": "2026-10-16T10:41:27.630Z",
"decision": "review",
"risk_score": 100,
"reasons": [
{
"rule": "screening_sanctions",
"version": 1,
"detail": "Close to Mohammed Sani Al-Rashid (SMP-0003) on sample, score 100"
}
],
"source": {
"id": "cus_1043",
"kind": "person",
"name": "Muhammad Sani Rashid"
},
"event": {
"type": "screening",
"occurred_at": "2026-10-16T10:41:27.630Z",
"source_id": "cus_1043"
}
}
Work the matches through match review. When every match is cleared and a second person has approved each one, the alert closes by itself as false_positive, and the audit log records alert.closed. A confirmed true match leaves the alert open, so someone decides what happens next. You can also close a screening alert through closure like any other.
Funds flow
GET /v1/alerts/{id}/flow
Money in and out of the alert's source, per hour, for the 24 hours up to its latest event. Money out is events whose source.entity_id is the source. Money in is events whose destination.entity_id is the source, so send destination on your events if you want this filled. Only hours with money moving are listed.
curl "$SENTINEL_URL/v1/alerts/c7d4e1a2-5b6c-4d7e-8f9a-0b1c2d3e4f5a/flow" -H "Authorization: Bearer $SENTINEL_KEY"
{
"entity_id": "cus_10442",
"currency": "GHS",
"since": "2026-10-14T11:21:40Z",
"until": "2026-10-15T11:21:40Z",
"hours": [
{
"hour": "2026-10-15T08:00:00Z",
"in": 41200,
"out": 0
},
{
"hour": "2026-10-15T09:00:00Z",
"in": 0,
"out": 38750
}
]
}
currency is taken from the events and assumes one currency per source. If the event behind the alert named no source, entity_id is absent and hours is empty.
Errors
| Endpoint | Code | error |
|---|---|---|
| All | 401 | missing or invalid API key |
All with {id} | 404 | alert not found |
PATCH | 400 | unknown status |
PATCH | 400 | status can only be open or in_review here; close alerts through /closure |
PATCH | 400 | assigned_to cannot be empty |
PATCH | 409 | alert is pending closure or closed |
POST .../notes | 400 | body must be 1 to 5000 characters |
POST .../closure | 400 | unknown disposition |
POST .../closure | 409 | only open or in_review alerts can be put up for closure |
POST .../closure/approve | 403 | the person who requested closure cannot approve it |
POST .../closure/approve, .../reject | 409 | alert is not pending closure |