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.

Updated On August 20, 2026
Add Sanctions Screening to Your Onboarding Flow in Under an Hour

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.

ScopeWhat it grantsGive it to the signup service?
entities:writePOST /entities, PATCH /entities/{external_id}, POST /entities/batchYes
entities:readGET /entities, entity detail, entity screening historyYes
cases:readGET /cases, case detail, case matches, blocking casesYes
monitoring:writePATCH /entities/{external_id}/monitoringYes
webhooks:manageGET/PUT/DELETE /webhooks, POST /webhooks/testConfigure once, then no
cases:writeRecord and reverse match decisions, add notes, close casesNo. 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.

PatternWhat the customer experiencesWhere it bitesFits
Synchronous screen, hard block on any matchInstant pass, or an outright rejection at the formEvery false positive becomes a lost customer, and false positives dominate match volumeVery low volume, very high risk onboarding
Synchronous screen, provisional account, hold on matchInstant pass, or an account created with funding and payouts disabled pending reviewRequires a real "restricted" account state and a review queue with an SLAMost fintech and marketplace onboarding
Asynchronous screen after signupAlways instantYou must still block value movement until the result arrives, so you have built the hold anyway plus a race conditionBulk 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_statusMeansYour app shouldCustomer sees
SCREENED_CLEARScreened, zero matches foundComplete signup normallyNothing
MATCHES_PENDING_REVIEWMatches found, but no case was createdTreat as a hold, not a clear. Check case_creation.reason_codeNeutral verification message
CASE_OPENA case exists and is openRestrict the account, route to reviewNeutral verification message
ENTITY_BLOCKEDA match was confirmed as a true match by a reviewerDo not onboard. Escalate to your reporting processNeutral message only
CASE_CLOSEDA case existed and was resolvedFollow the resolution, not the historyNothing, 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 codeMeansDo
400 VALIDATION_ERRORBad payload. error.details.fields lists the offendersFix the mapping. Never retry unchanged
401 INVALID_API_KEYKey invalid, revoked, or expiredAlert. Do not retry
403 FORBIDDENKey lacks a scope. details.missing_scopes names itFix the key. Do not retry
409 EXTERNAL_ID_CONFLICTThat external_id already existsTreat as already created. Fetch it with GET /entities/{external_id}
409 IDEMPOTENCY_CONFLICTSame key reused with a different bodyA bug in how you derive the key. Do not retry
429 RATE_LIMIT_EXCEEDEDYour organization's bucket is exhausted. It is shared by all your keysBack off at least Retry-After seconds, then retry
500, 503Server or screening service problemRetry 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.

EventPayload carriesUse it to
case.openedcase_id, risk_level, match_count, triggerRestrict the account and open a review task
entity.status_changedexternal_id, previous_status, new_status, case_idKeep your own customer record in step
case.closedcase_idLift or confirm the restriction
monitoring.run.completedrun_id, entities_checked, new_cases_countAssure 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

CheckWhy it matters
Match threshold reviewed, and the reasoning written downThe 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 understoodCritical at 90, high at 80, medium at 60 by default. These drive case creation and your own routing rules
Automatic case creation setting confirmedWith 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 endTake 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 endConfirms you are not holding everybody
Alert routing has a named owner and an SLAThe 10-business-day reporting clock starts at the event, not when someone notices the queue
API key and webhook secret both in your secret managerNeither can be retrieved after creation, only rotated
Correlation IDs logged against your signup recordsThe only practical way to trace one specific screen afterwards
Rate limit headroom checkedOne 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 restrictsThe 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


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.