Add Sanctions Screening to Your Onboarding Flow in Under an Hour
Screen a customer at signup with one POST: sync vs async, handling clear, match and pending review, retries, and monitoring webhooks.

A sanctions screen at signup is one authenticated POST that returns the answer in the same request. The integration is small. The hour goes on four decisions around it: whether a match stops the signup, what the customer is told when it does, what your code does when the screening call fails, and how you find out about a match that appears six months later.
This walks through all four against the DeRisk Hub REST API, with request and response shapes taken from the live OpenAPI spec as of August 2026. Full reference: the developer docs.
What does the minimum integration actually look like?
One call. POST /api/v1/entities creates the customer as a screened entity, screens the name against every list configured for your organization, opens a case if a match qualifies, and returns the outcome in the 201 body. There is no submit-then-poll step for a single entity and no callback to wait for.
Before writing any code, create an API key (Admin, then API Keys). The plaintext key is shown once at creation and only a SHA-256 hash is stored, so it cannot be recovered later, only revoked and replaced. Keys look like drsk_live_ followed by 43 base64url characters, and every key carries an explicit scope list.
| Scope | What it grants | Give it to the signup service? |
|---|---|---|
entities:write | POST /entities, PATCH /entities/{external_id}, POST /entities/batch | Yes |
entities:read | GET /entities, entity detail, entity screening history | Yes |
cases:read | GET /cases, case detail, case matches, blocking cases | Yes |
monitoring:write | PATCH /entities/{external_id}/monitoring | Yes |
webhooks:manage | GET/PUT/DELETE /webhooks, POST /webhooks/test | Configure once, then no |
cases:write | Record and reverse match decisions, add notes, close cases | No. This clears sanctions hits programmatically. It belongs to your review tooling, never to a signup endpoint |
The call itself:
curl -X POST "$DERISKHUB_API_BASE/api/v1/entities" \
-H "Authorization: Bearer $DERISKHUB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup-CUST-88213" \
-d '{
"external_id": "CUST-88213",
"entity_type": "individual",
"name": "Vladimir Ivanovich Petrov",
"country": "RU",
"birth_date": "1975-03-14"
}'
external_id is your own customer reference, and it becomes the key for every later call on that entity (GET /entities/CUST-88213, monitoring toggles, screening history). Omit it and one is generated with an ENT- prefix, which works but leaves your two systems joined by nothing. country, nationality, and flag accept either an ISO alpha-2 code or a country name, because the preprocessor normalizes both. You do not need a cleanup pass on your signup form's country field.
A trimmed 201 response for a name that hit:
{
"external_id": "CUST-88213",
"entity_name": "Vladimir Ivanovich Petrov",
"entity_type": "individual",
"entity_status": "CASE_OPEN",
"risk_level": "Critical",
"blocking_case_count": 1,
"blocking_cases": [
{ "case_id": "8f2c...", "status": "OPEN", "risk_level": "Critical", "blocked_at": "2026-08-19T09:14:22.481Z" }
],
"case_creation": { "outcome": "created", "manual_create_eligible": false },
"monitoring": { "enabled": true, "last_run_at": null },
"needs_rescreen": false,
"screening_source": "API",
"created_at": "2026-08-19T09:14:22.102Z"
}
There is a second endpoint, POST /api/v1/screenings, which returns the same entity plus the full screening record (matches, lists_searched, screening_duration_ms). Use it when you want the match detail inline. Use POST /entities when you only need the outcome. Both persist the entity, and neither offers a stateless screen-and-forget mode.
Should you block signup on a match, or screen asynchronously and flag for review?
Screen synchronously, block selectively. Run the screen inside the signup request so you always hold the answer before the account exists, then let the result decide: a clear proceeds untouched, a qualifying match creates an account that cannot move value until a human has reviewed it. Screening asynchronously after signup is defensible only if you can genuinely hold value transfer until the result lands, and it is harder to evidence later.
The distinction most teams miss on the first attempt: the screen is fast and automated, the review is slow and human. Conflating the two produces the two classic failures, either a signup flow that hangs on a compliance queue or an account that transacts before anyone looked at it.
| Pattern | What the customer experiences | Where it bites | Fits |
|---|---|---|---|
| Synchronous screen, hard block on any match | Instant pass, or an outright rejection at the form | Every false positive becomes a lost customer, and false positives dominate match volume | Very low volume, very high risk onboarding |
| Synchronous screen, provisional account, hold on match | Instant pass, or an account created with funding and payouts disabled pending review | Requires a real "restricted" account state and a review queue with an SLA | Most fintech and marketplace onboarding |
| Asynchronous screen after signup | Always instant | You must still block value movement until the result arrives, so you have built the hold anyway plus a race condition | Bulk import of an existing book, not live signup |
The screen runs inside your signup request and returns with it, so what your users feel is one API call plus your own network round trip. POST /screenings returns screening_duration_ms on every call, which is the honest way to size this: measure it against your own traffic rather than taking any vendor's published figure, ours included. Set an explicit client timeout regardless, then decide what a timeout means. It is not a clear.
How do you handle the three outcomes: clear, match, and pending review?
Every response carries an entity_status field that collapses the screening result, the case-creation outcome, and any prior reviewer decisions into a single value. A completed signup lands on one of the first three values below. The last two appear when you screen an entity again later. A sixth value, NEW, means an entity exists but has never been screened, which this flow never produces.
entity_status | Means | Your app should | Customer sees |
|---|---|---|---|
SCREENED_CLEAR | Screened, zero matches found | Complete signup normally | Nothing |
MATCHES_PENDING_REVIEW | Matches found, but no case was created | Treat as a hold, not a clear. Check case_creation.reason_code | Neutral verification message |
CASE_OPEN | A case exists and is open | Restrict the account, route to review | Neutral verification message |
ENTITY_BLOCKED | A match was confirmed as a true match by a reviewer | Do not onboard. Escalate to your reporting process | Neutral message only |
CASE_CLOSED | A case existed and was resolved | Follow the resolution, not the history | Nothing, if cleared |
MATCHES_PENDING_REVIEW is the state worth handling deliberately, because it is a configuration outcome rather than a screening outcome. It means the screen found something and no case was opened, and the case_creation object tells you why: AUTO_CREATE_DISABLED if automatic case creation is switched off for your organization, or CRITICAL_ONLY_NO_CANDIDATES if case creation is restricted to critical matches and none of the hits reached that band. Code that reads "no case ID" as "clear" will pass matched customers straight through the moment someone changes a setting in the admin console.
What you show the customer is a regulatory question, not a UX one
Say as little as possible, and never name the reason. A neutral message ("We need a little longer to verify your details and will email you within one business day") is the right answer for every held outcome above.
The constraint comes from two directions. Disclosing that a suspicious activity report has been filed, or is being prepared, is prohibited outright in the US under 31 U.S.C. 5318(g)(2), and the UK carries an equivalent tipping-off offence at section 333A of the Proceeds of Crime Act 2002. Beyond that, a screening hit is an unconfirmed allegation about a private individual until a reviewer says otherwise. Telling someone they appeared on a sanctions list when they merely share a name with a designated person is its own problem.
If the match is confirmed, the timelines get concrete quickly. Blocked property must be reported to OFAC within 10 business days, and rejected transactions carry their own 10-business-day report [31 CFR 501.603, 501.604]. Design the review queue so that deadline is reachable from the moment the case opens.
What should your code do when the screening call fails?
Fail closed. A screening call that errors, times out, or returns 503 has told you nothing about the customer, so the only safe reading is "not yet screened", which means the account does not get funded. Then retry only what is retryable, and carry an Idempotency-Key so a retry cannot create a second entity.
| Status and code | Means | Do |
|---|---|---|
400 VALIDATION_ERROR | Bad payload. error.details.fields lists the offenders | Fix the mapping. Never retry unchanged |
401 INVALID_API_KEY | Key invalid, revoked, or expired | Alert. Do not retry |
403 FORBIDDEN | Key lacks a scope. details.missing_scopes names it | Fix the key. Do not retry |
409 EXTERNAL_ID_CONFLICT | That external_id already exists | Treat as already created. Fetch it with GET /entities/{external_id} |
409 IDEMPOTENCY_CONFLICT | Same key reused with a different body | A bug in how you derive the key. Do not retry |
429 RATE_LIMIT_EXCEEDED | Your organization's bucket is exhausted. It is shared by all your keys | Back off at least Retry-After seconds, then retry |
500, 503 | Server or screening service problem | Retry with backoff, then fail closed |
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
export async function screenAtSignup(customer: Customer): Promise<EntityDetail> {
const idempotencyKey = `signup-${customer.id}`;
for (let attempt = 0; attempt < 3; attempt++) {
const response = await fetch(`${process.env.DERISKHUB_API_BASE}/api/v1/entities`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DERISKHUB_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
'X-Correlation-Id': customer.signupTraceId,
},
body: JSON.stringify({
external_id: customer.id,
entity_type: 'individual',
name: customer.fullName,
country: customer.countryCode,
birth_date: customer.dateOfBirth,
}),
signal: AbortSignal.timeout(5_000),
});
if (response.ok) {
return (await response.json()) as EntityDetail;
}
if (!RETRYABLE.has(response.status)) {
const body = await response.json();
throw new ScreeningRejected(body.error.code, body.correlation_id);
}
const retryAfter = Number(response.headers.get('Retry-After') ?? 0);
await sleep(Math.max(retryAfter * 1_000, 500 * 2 ** attempt));
}
// Nothing was learned about this customer. Do not treat as clear.
throw new ScreeningUnavailable(idempotencyKey);
}
The same Idempotency-Key returns the cached original response for 24 hours, so a retried signup cannot double-create, while reusing that key with a different body is rejected outright rather than silently overwriting. And every response carries a correlation_id in the body and as the X-Correlation-Id header, echoing the one you sent. Log it next to your own signup ID. It is the join key when you ask support what happened to one specific screen.
How do you hear about a match that appears after onboarding?
Monitoring re-screens stored entities as list data changes, and opens a case when a new match qualifies. A webhook then pushes that case to your endpoint without you polling for it. Both are configured once: monitoring is per entity and on by default, and there is a single webhook configuration per organization.
The per-entity toggle, if you need to set it explicitly:
curl -X PATCH "$DERISKHUB_API_BASE/api/v1/entities/CUST-88213/monitoring" \
-H "Authorization: Bearer $DERISKHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "enabled": true }'
And the webhook itself:
curl -X PUT "$DERISKHUB_API_BASE/api/v1/webhooks" \
-H "Authorization: Bearer $DERISKHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://your-service.example.com/webhooks/deriskhub",
"enabled": true,
"events": ["case.opened", "entity.status_changed", "monitoring.run.completed"]
}'
Omit secret and one is generated for you, returned exactly once as secret_once. The target_url must be HTTPS and must not resolve to a private or loopback address. Saving a configuration does not send a test delivery, so call POST /api/v1/webhooks/test before you rely on it.
| Event | Payload carries | Use it to |
|---|---|---|
case.opened | case_id, risk_level, match_count, trigger | Restrict the account and open a review task |
entity.status_changed | external_id, previous_status, new_status, case_id | Keep your own customer record in step |
case.closed | case_id | Lift or confirm the restriction |
monitoring.run.completed | run_id, entities_checked, new_cases_count | Assure yourself monitoring actually ran |
Verify the signature against the raw body, before any parse and re-serialize reorders the keys:
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));
}
A failed delivery is retried twice with backoff (500ms, then up to 2s) and then recorded as failed. No delivery system is a guarantee, so run a reconciliation poll on a schedule as well: GET /api/v1/entities?status_changed_since=<timestamp> returns only the entities whose status actually moved since your last sync. Webhooks are the primary signal, the poll is the safety net.
What to check before you turn it on
| Check | Why it matters |
|---|---|
| Match threshold reviewed, and the reasoning written down | The default is 70 on a 0 to 100 scale, with a platform floor of 50. Lowering it to cut alert volume is a finding waiting to happen, and changing it with no documented rationale is worse |
| Risk bands understood | Critical at 90, high at 80, medium at 60 by default. These drive case creation and your own routing rules |
| Automatic case creation setting confirmed | With it off, matched entities land in MATCHES_PENDING_REVIEW and there is no case for anyone to review |
| A known-positive name screened end to end | Take a name straight from OFAC's published SDN search, run it through your real signup path, and confirm a case opens and your restriction fires |
| A known-negative name screened end to end | Confirms you are not holding everybody |
| Alert routing has a named owner and an SLA | The 10-business-day reporting clock starts at the event, not when someone notices the queue |
| API key and webhook secret both in your secret manager | Neither can be retrieved after creation, only rotated |
| Correlation IDs logged against your signup records | The only practical way to trace one specific screen afterwards |
| Rate limit headroom checked | One bucket per organization, shared across every key and every endpoint, so extra keys do not buy throughput. X-RateLimit-Limit and X-RateLimit-Remaining on each response report your actual limit and what is left of it. Current defaults are in the rate limits guide |
| The restricted account state actually restricts | The most common gap of all: the case opens, the flag is set, and payouts still run |
Frequently asked questions
How long does the screening call take?
Measure it rather than accepting a published figure from any vendor, ours included. Every POST /screenings response includes screening_duration_ms for that specific call, so you can build your own p50 and p99 from real traffic within a day of going live. Your end-to-end number will also carry the network round trip from wherever your service runs, which no vendor benchmark captures.
Do I pay again when a customer is re-screened? No. The meter is the peak number of active entities under management during the billing period, not the number of API calls. Re-screening someone you already created, whether through monitoring or an explicit rescreen, does not add to it. Detail on pricing.
Can I screen a name without storing the person?
Not through this API. Both POST /entities and POST /screenings persist an entity, deliberately: a screen you cannot produce a record of two years later is not much use under examination. If you need a genuine no-store check, that is a different product shape, so ask before building on the assumption.
Should the signup service be able to clear a match?
No. Clearing a match is a compliance decision that requires a named human actor and a written justification, which is why it sits behind a separate cases:write scope that is never part of any select-all option. Keep it out of the key your signup path uses.
What do I tell a customer whose signup is held? That verification is taking longer, and when they will hear back. Nothing about sanctions, lists, or matches. See the disclosure note above.
Citations
- 31 CFR 501.603 and 501.604 (blocked property and rejected transaction reports, 10 business days): https://www.ecfr.gov/current/title-31/part-501
- 31 U.S.C. 5318(g)(2) (prohibition on disclosing a suspicious activity report): https://www.law.cornell.edu/uscode/text/31/5318
- Proceeds of Crime Act 2002, section 333A (tipping off): https://www.legislation.gov.uk/ukpga/2002/29/section/333A
- DeRisk Hub API reference, guides, and interactive spec: https://deriskhub.com/developers
Ship the screen, then ship the queue
The code above is an afternoon at most. What takes longer is everything around it: a restricted account state that genuinely restricts, a review queue with an owner, and a record you can reconstruct later.
DeRisk Hub gives you all three behind one API: screening against sanctions, export-control, and PEP list sources, automatic case creation, ongoing monitoring with webhooks, and every decision written to an immutable audit trail. Start with the developer docs, read how sanctions screening actually works or why name matching is the hard part, then start your free trial, or go to DeRiskHub.com.