Build with the DeRisk Hub API

Screen entities, manage cases, and receive webhook events from your own systems through a REST API. Start with the quickstart guide, then explore the full interactive reference.

Quickstart

This walks through the smallest useful loop: create a key, screen an entity, and check whether it opened a case.

1. Create an API key

Admin → API Keys → Create Key. Grant at minimum entities:write, entities:read, screening:write, cases:read. Copy the plaintext key shown — it is not shown again.

2. Create and screen an entity

POST /api/v1/entities
Authorization: Bearer drsk_live_...
Content-Type: application/json

{
  "external_id": "CUST-001",
  "entity_type": "individual",
  "name": "Vladimir Ivanovich Petrov",
  "country": "RU",
  "birth_year": 1975
}

Creating an entity screens it automatically — the 201 response is the entity detail, including its current entity_status and any matches found.

external_id is optional: if omitted, the API generates one (prefixed ENT-, e.g. ENT-7K2M9QX4TB) and returns it in the response. Supplying your own customer reference ID is recommended — it keeps screening records traceable to your system and is the key you use on every entity endpoint (GET /entities/{external_id}, etc.). Note that omitting it also weakens retry safety: use an Idempotency-Key header so a retried create doesn't produce a duplicate entity (see idempotency.md).

Alternatively, use POST /screenings with name + entity_type (or external_id alone to rescreen an entity you already created). This still creates and persists an entity — like POST /entities, it auto-generates an ENT- prefixed external_id if you don't supply one — it's just a single combined create-and-screen call rather than two.

3. Check for a case

If the screening produced a qualifying match, a case was opened. List it:

GET /api/v1/entities/CUST-001/blocking-cases
Authorization: Bearer drsk_live_...

This is the entity-scoped way to find a case (GET /cases itself filters by status/risk_level/created_reason/date range, not by entity — see pagination-and-rate-limits.md). Once you have a case_id, pull match detail with GET /cases/{case_id}/matches.

When recording a decision, choose decision_type from the pair allowed for that match's match_category (sanctions → True Match / False Positive; PEP/RCA → Confirmed / Not a Match). Details: Case decisions.

4. (Bulk alternative) Batch upload + poll

For many entities at once:

POST /api/v1/entities/batch
{
  "review_confirmed": true,
  "entities": [ { "external_id": "CUST-001", "entity_type": "individual", "name": "..." }, ... ]
}

returns a job_id and handshake_token (also mirrored under webhook.handshake_token). Validation is all-or-nothing: if any row fails validation (e.g. a missing name), the whole request is rejected with 422 VALIDATION_FAILURES_EXCEEDED and error.details.validation_errors listing the offending rows — no job is created, and no rows are screened, including the valid ones. Fix and resubmit the full batch. Poll it (respect the job's own rate limit — don't tight-loop):

GET /api/v1/jobs/{job_id}

until status is terminal, then list cases as in step 3.

failed_count being non-zero doesn't necessarily mean something broke — a row can be deliberately rejected (e.g. a placeholder value like N/A) rather than genuinely failing. Check GET /api/v1/jobs/{job_id}/row-results for which rows and why; see Webhooks for the full breakdown.

Correlation IDs

Every /api/v1/** response includes a per-request correlation_id (UUID) in the body and echoes the same value as the X-Correlation-Id response header. Errors use the same field in the standard envelope.

You may send X-Correlation-Id or X-Request-Id (UUID) on the request to supply your own trace id; otherwise the server generates one. Include the value when contacting support — it joins API responses, usage logs, and (when applicable) webhook deliveries.

Next steps

Authentication & Scopes

Enabling the API

API access is enabled per organization. If every route responds 503 SERVICE_UNAVAILABLE, API access has not yet been switched on for your organization — contact support to enable it.

Creating a key

Keys are created in Admin → API Keys, scoped to one or more of the scopes below. The plaintext key is shown once, at creation time — store it securely; only a SHA-256 hash is kept server-side, so it cannot be recovered later (only revoked and replaced).

Format: drsk_live_<43 base64url characters> (256 bits of entropy).

Authenticating a request

Authorization: Bearer drsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

An invalid, revoked, or expired key returns 401 INVALID_API_KEY. Revocation is honored within 60 seconds worst-case.

Scopes

Every key is granted one or more scopes at creation time. A request to an endpoint requiring a scope the key doesn't have returns 403 FORBIDDEN with details: { required_scopes, missing_scopes }.

ScopeGrants
screening:writePOST /screenings
screenings:readGET /screenings, GET /screenings/{screening_id}, GET /screenings/{id}/explanation
entities:readGET /entities, GET /entities/{external_id}, GET /entities/{external_id}/screenings, GET /jobs/{job_id}, GET /jobs/{job_id}/row-results
entities:writePOST /entities, PATCH /entities/{external_id}, POST /entities/batch
entities:deleteDELETE /entities/{external_id}
monitoring:writePATCH /entities/{external_id}/monitoring
cases:readGET /cases, GET /cases/{case_id}, GET /cases/{case_id}/matches, GET /entities/{external_id}/blocking-cases
cases:writeDecision, reverse-decision, note, and close endpoints under /cases
webhooks:manageGET/PUT/DELETE /webhooks, POST /webhooks/test
usage:readGET /usage
lists:readGET /lists

cases:write — grant carefully

This scope lets an API key record and reverse match decisions (clear a sanctions/PEP hit, close a case) — the same action an analyst takes in the UI. It is not included in any "select all" convenience option in the Admin key dialog; it must be granted explicitly, and the UI warns that it "allows programmatic clearing of screening matches — grant only to trusted systems."

Every decision made this way still requires a named human/system actor (actor.external_ref in the request body) — the audit trail never records just "an API key" as the decider. Decision writes are also rate-limited separately and monitored for unusual velocity (see pagination-and-rate-limits.md).

Decision types are category-specific. Sanctions matches accept only True Match / False Positive; PEP and RCA matches accept only Confirmed / Not a Match. See Case decisions for the full matrix and error shape.

If you're wiring an AI agent or other autonomous system to cases:write, require an explicit confirmation step before the agent calls the decision endpoint — don't let it decide unattended.

Case decisions

Recording a match decision (POST /api/v1/cases/{case_id}/matches/{match_id}/decision) requires scope cases:write, a mandatory actor, and a decision_type that is valid for that match's category.

Decision type matrix

All four enum values appear in the OpenAPI schema. Only two are valid for any given match — choose from the pair that matches match_category on the match (from GET /cases/{case_id}/matches or case detail):

match_categoryAllowed decision_typeMeaning
Sanctions (anything other than PEP / RCA, typically SANCTIONS)True Match, False PositiveConfirm a sanctions hit, or clear it as a false positive
PEP or RCAConfirmed, Not a MatchConfirm PEP/RCA exposure, or clear it as not a match

Using a type from the wrong pair returns:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "PEP/RCA matches require Confirmed or Not a Match decision"
  },
  "correlation_id": "...",
  "timestamp": "..."
}

(or "Sanctions matches require True Match or False Positive decision" for the sanctions-side mismatch).

Recommended flow

  1. GET /api/v1/cases/{case_id}/matches (or case detail) and read each match's match_category.
  2. Pick decision_type from the allowed pair for that category.
  3. POST .../decision with justification (≥ 10 chars) and actor.external_ref (and optional name/email).
  4. Use an Idempotency-Key if retries are possible (see idempotency.md).

Related

Pagination & Rate Limits

Pagination

Every list endpoint (GET /entities, GET /cases, GET /screenings) takes the same two query params:

ParamDefaultMaxMin
page1—1
page_size20–50 (varies by endpoint)100endpoint-specific (see below)

Per-endpoint page_size floors: GET /entities min 10 (allowed values effectively 10–100; UI-aligned); GET /cases and GET /screenings min 1.

The response is always { <resource>, total, page, page_size } — e.g. GET /entities returns { entities: [...], total, page, page_size }. There is no cursor/next_page_token — pages are computed from page/page_size directly, so total combined with page_size tells you when to stop.

Detail endpoints that return a bounded sub-resource (GET /cases/{case_id}/matches, GET /entities/{external_id}/blocking-cases) are not paginated — they return the full set.

Rate limits

Every response to a request bearing a valid API key carries the following headers — including scope-forbidden (403) responses, since the request is counted against the tenant's bucket before the scope check runs (a key must not be able to bypass the limiter by hitting endpoints it lacks scope for). Responses rejected before a key is identified (missing/malformed Authorization, unrecognized key) carry no rate-limit headers, since no tenant bucket exists yet to report on.

HeaderMeaning
X-RateLimit-LimitRequests allowed per window
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-WindowWindow length, in seconds
X-RateLimit-ResetSeconds until the current window resets (when known)

Usage is counted per tenant: all of your API keys and all endpoints share one bucket, so creating more keys or spreading calls across resources does not raise the effective throughput. The default limit is 100 requests/minute — if you need a higher limit, contact support. A request that exceeds the limit gets 429 RATE_LIMIT_EXCEEDED plus a Retry-After header (seconds) — back off for at least that long before retrying.

Separate, stricter limit for case decisions: POST /cases/{case_id}/matches/{match_id}/decision and .../reverse-decision are governed by their own limiter (30 requests/minute per tenant by default), independent of the general limit above — you can be well under the general limit and still get throttled on decision endpoints specifically. On decision endpoints the X-RateLimit-* headers describe whichever of the two buckets is closer to exhaustion, so X-RateLimit-Remaining always reflects the limit that will actually throttle the next call.

Job polling: GET /jobs/{job_id} has its own poll-interval limiter (separate from both of the above) — polling faster than the allowed interval also returns 429 with Retry-After. Poll at a sensible interval (a few seconds) rather than tight-looping.

Idempotency

Send an Idempotency-Key header (any unique string, e.g. a UUID you generate per logical operation) to safely retry a request without double-processing it.

Supported on:

  • POST /api/v1/entities (also accepts the key as body field idempotency_key — the header takes precedence if both are sent)
  • POST /api/v1/cases/{case_id}/matches/{match_id}/decision
  • POST /api/v1/cases/{case_id}/matches/{match_id}/decision/reverse
  • POST /api/v1/cases/{case_id}/notes
  • PATCH /api/v1/cases/{case_id}/close

Behavior

  • The key is scoped per tenant — the same key value used by two different tenants never collides.
  • On first use, the request is processed normally and the response is cached against the key for 24 hours.
  • A retry with the same key and same request body returns the cached response immediately — the operation is not re-executed.
  • A retry with the same key but a different request body is rejected with 409 IDEMPOTENCY_CONFLICT — this catches the bug class where a client reuses a key across two logically different requests.
  • Idempotency-Key is optional. Without it, retries are treated as new requests (e.g. a second POST /entities retry with the same external_id fails with 409 EXTERNAL_ID_CONFLICT instead of returning the original response). If you also omit external_id (letting the API auto-generate one), an unkeyed retry silently creates a duplicate entity — always send an Idempotency-Key in that case.

Recommendation

Generate one key per logical operation (e.g. "create entity CUST-001") at the point you first attempt it, and reuse that same key for any retries of that same attempt — not a new key per HTTP call.

Public API Webhooks

DeRisk Hub can push events to your endpoint instead of (or alongside) polling. One webhook configuration per tenant, managed via GET/PUT/DELETE /api/v1/webhooks (scope webhooks:manage).

Configuring a webhook

PUT /api/v1/webhooks
Authorization: Bearer drsk_live_...
Content-Type: application/json

{
  "target_url": "https://your-service.example.com/webhooks/deriskhub",
  "enabled": true,
  "events": ["case.opened", "case.decision.recorded", "monitoring.run.completed"]
}
  • target_url must be HTTPS and must not resolve to a private, loopback, or link-local address — this is checked synchronously on save (DNS resolution + range check), not re-checked per delivery.
  • Omit secret to have one generated for you; it is returned once as secret_once in the response and never again (GET /webhooks only ever shows secret_last4).
  • Omit events (or send an empty array) to subscribe to every event type.
  • Saving valid config does not send a live test delivery — use POST /webhooks/test separately to confirm your endpoint is reachable before relying on it (see below).

Sending a test delivery

POST /webhooks/test (scope webhooks:manage) sends a one-off webhook.test event. Two modes:

  • No body (or empty body) — tests the saved configuration: the delivery goes to the configured target_url, signed with the saved secret (even if the webhook is currently disabled). Returns 404 WEBHOOK_NOT_CONFIGURED if nothing is saved.
  • With target_url + secret — tests an arbitrary endpoint before saving it, signed with the secret you pass. Both fields must be provided together. Nothing is persisted either way — use PUT /webhooks to save.
POST /api/v1/webhooks/test
Authorization: Bearer drsk_live_...
Content-Type: application/json

{
  "target_url": "https://your-service.example.com/webhooks/deriskhub",
  "secret": "the-same-secret-you-plan-to-save"
}

Response: { "delivered": true }. The delivered payload has job_id: "TEST-JOB" and a handshake_token.

Correlation ID

Every delivery includes a correlation_id (UUID) in the JSON body and as the X-Correlation-Id response header on the outbound POST. When the event was triggered by an HTTP API call that still has request context, this matches the correlation_id / X-Correlation-Id from that call. Async emitters (for example entity.status_changed after a status transition) mint a delivery-scoped UUID instead — still useful for joining your receiver logs to Admin → Settings → Webhooks delivery history.

Signature verification must use the raw body as received (which includes correlation_id).

Verifying the signature

Every delivery carries:

HeaderMeaning
X-Webhook-Signaturehex(HMAC-SHA256(secret, raw_request_body))
X-Webhook-TimestampISO-8601 delivery time
X-Webhook-EventThe event name, e.g. case.opened
X-Correlation-IdSame UUID as the payload correlation_id

Verify against the raw request body (before any JSON parsing/re-serialization reorders keys) using your webhook secret.

Node.js:

const crypto = require('crypto');

function isValidSignature(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

Python:

import hmac
import hashlib

def is_valid_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)

Retry policy

A failed delivery (non-2xx response, timeout, or network error) is retried up to 2 additional times with exponential backoff (500ms → 2000ms). After all attempts fail, the delivery is recorded as failed and no further retry is scheduled — reconcile via delta polling (see below) if you suspect a missed event. Every attempt (success or failure) is recorded and visible in Admin → Settings → Webhooks.

outcome_counts (batch job completion events only)

batch.upload.success, batch.upload.failed, batch.screening.success, and batch.screening.failed carry an outcome_counts object, in addition to failed_count:

"outcome_counts": { "success": 94, "rejected": 3, "failed": 1, "warned": 2 }

failed_count alone doesn't tell you why a row didn't go through, and lumps two very different situations together:

  • rejected — the row was refused on purpose. The name matched a known placeholder (N/A, TEST, ...) or had nothing screenable in it. This usually means the source data needs fixing, not a retry.
  • failed — something broke unexpectedly while processing the row. Worth investigating if it recurs.
  • warned — the row succeeded, but with a caveat worth a human look (e.g. a name mixing two alphabets).

To see which row was which and why, call GET /api/v1/jobs/{job_id}/row-results (scope entities:read, paginated, filterable by ?outcome=). outcome_counts in the webhook payload matches counts_by_outcome from that endpoint.

Handshake token (batch jobs only)

batch.upload.* and batch.screening.* events (and webhook.test) carry a handshake_token in the payload — a per-job random token you can use to correlate a webhook delivery back to the POST /entities/batch response that triggered it, without trusting the payload alone. The batch submit response always includes this token (top-level handshake_token and webhook.handshake_token), even when no webhook is configured yet — so you can configure webhooks later and still correlate deliveries. Case, decision, and monitoring events do not carry a handshake token (they aren't tied to a single job submission).

Delta polling (reconciliation / no-webhook fallback)

If you can't receive webhooks, or want to reconcile against missed deliveries:

  • GET /api/v1/cases?created_from=...&updated_from=... — cases opened or changed since your last sync (covers case.opened/case.decision.*/case.closed).
  • GET /api/v1/screenings?from=...&to=... — screenings recorded in a time window (covers rescreens triggered by monitoring or updates).
  • GET /api/v1/entities?status_changed_since=... — entities whose status transitioned (e.g. SCREENED_CLEAR → CASE_OPEN, or back on resolution) since your last sync (covers entity.status_changed). Entities whose status never changed are excluded.

Recommended pattern: treat webhooks as the primary signal and run a delta poll (e.g. hourly) as reconciliation, not as the primary sync mechanism.

Event catalog

EventPayload fields (beyond event, handshake_token, correlation_id)
batch.screening.cancelledjob_id, job_type, status, source_job_id, completed_at
batch.screening.failedjob_id, job_type, status, source_job_id, total_count, processed_count, failed_count, completed_at, outcome_counts
batch.screening.processingjob_id, job_type, status, source_job_id, total_count, processed_count, failed_count
batch.screening.queuedjob_id, job_type, status, source_job_id, total_count, processed_count, failed_count
batch.screening.successjob_id, job_type, status, source_job_id, total_count, processed_count, failed_count, completed_at, outcome_counts
batch.upload.failedjob_id, job_type, status, total_count, processed_count, failed_count, completed_at, outcome_counts
batch.upload.processingjob_id, job_type, status, total_count, processed_count, failed_count
batch.upload.queuedjob_id, job_type, status, total_count, processed_count, failed_count
batch.upload.successjob_id, job_type, status, total_count, processed_count, failed_count, completed_at, outcome_counts
case.closedcase_id
case.decision.recordedcase_id, match_id, decision_type (false_positive
case.decision.reversedcase_id, match_id
case.openedcase_id, risk_level, match_count, trigger (ON_DEMAND
entity.status_changedscreened_entity_id, external_id, entity_name, previous_status, new_status, case_id (nullable), changed_at
monitoring.run.completedrun_id, entities_checked, new_cases_count
webhook.testjob_id ("TEST-JOB"), job_type, status, total_count, processed_count, failed_count

Every payload shares these fields: event (the event name), correlation_id (UUID), plus event-specific fields listed above.

Public API Error Code Catalog

Error envelope

Every error on /api/v1/** uses the standard envelope:

{
  "success": false,
  "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "details": {} },
  "correlation_id": "3fa2c11e-2b2e-4b1a-9c3d-6a7e2f8b9c10",
  "timestamp": "2026-07-06T12:00:00.000Z"
}

Correlation IDs

correlation_id is a per-request UUID (server-generated, or taken from inbound X-Correlation-Id / X-Request-Id when a valid UUID is supplied). It is echoed as the X-Correlation-Id response header and is not the API key id. Include it when contacting support.

Error details

details is present on some codes (e.g. VALIDATION_ERROR includes { fields: [...] }, RATE_LIMIT_EXCEEDED includes { retry_after_seconds }, FORBIDDEN includes { required_scopes, missing_scopes }) and absent on others. VALIDATION_ERROR is also returned for semantic decision-type mismatches (sanctions vs PEP/RCA) with a descriptive message and no fields — see the Case decisions guide.

Error code catalog

CodeHTTP StatusCategory
INVALID_ENTITY_TYPE400Validation
INVALID_EXTERNAL_ID_FORMAT400Validation
MISSING_REQUIRED_FIELD400Validation
VALIDATION_ERROR400Validation
WEBHOOK_EVENT_INVALID400Validation
WEBHOOK_URL_NOT_ALLOWED400Validation
API_KEY_EXPIRED401Authentication
API_KEY_REVOKED401Authentication
INVALID_API_KEY401Authentication
UNAUTHORIZED401Authentication
AI_MESSAGE_LIMIT_REACHED402Other
FORBIDDEN403Authorization
CASE_NOT_FOUND404Not Found
ENTITY_NOT_FOUND404Not Found
JOB_NOT_FOUND404Not Found
NOT_FOUND404Not Found
SCREENING_NOT_FOUND404Not Found
WEBHOOK_NOT_CONFIGURED404Not Found
CASE_ALREADY_CLOSED409Conflict
CASE_NOT_READY_FOR_CLOSURE409Conflict
DECISION_ALREADY_EXISTS409Conflict
ENTITY_HAS_OPEN_CASES409Conflict
EXTERNAL_ID_CONFLICT409Conflict
IDEMPOTENCY_CONFLICT409Conflict
PAYLOAD_TOO_LARGE413Payload Too Large
BATCH_SIZE_EXCEEDED422Unprocessable Entity
VALIDATION_FAILURES_EXCEEDED422Unprocessable Entity
BULK_JOB_QUOTA_EXCEEDED429Rate Limiting
RATE_LIMIT_EXCEEDED429Rate Limiting
DATABASE_ERROR500Server Error
INTERNAL_SERVER_ERROR500Server Error
NOT_IMPLEMENTED501Other
SCREENING_SERVICE_UNAVAILABLE503Service Unavailable
SERVICE_UNAVAILABLE503Service Unavailable