API documentation
Policy365 Sanctions Screening API
Screens people and companies against the UK Sanctions List (FCDO), the EU's consolidated financial sanctions list, the UN Security Council Consolidated List and the Consolidated Canadian Autonomous Sanctions List, and looks UK companies up at Companies House to screen their officers and persons with significant control.
Getting started
- Start a free trial. A card is taken at sign-up; nothing is charged during the 14 days.
- Your API key is shown once, when the account is made. Copy it then: it can't be shown again, because only a hash of it is kept.
- More keys can be issued, and old ones revoked, from your account (sign in by a link sent to your email). Two keys can be live at once, so a key can be changed without a gap.
A key looks like ssk_ followed by 43 characters. Keep it out of source code and out of anything
shared: the examples below read it from an environment variable, SANCTIONS_API_KEY.
The base URL is:
https://screening.policy365.cloud
Requests and answers are JSON (UTF-8), apart from the CSV endpoints. Field names are camelCase. Times are UTC, in ISO 8601.
Authentication
Every call to /api/… needs your key in the X-Api-Key header. Without a live key the
answer is 401. A key stops working when it is revoked or expires, or when the account is closed;
the change reaches the service within a minute. GET /health needs no key.
X-Api-Key: ssk_your_key_here
A first call
This screens one person. Replace the key, or set SANCTIONS_API_KEY first.
curl -sS https://screening.policy365.cloud/api/screen \
-H "X-Api-Key: $SANCTIONS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reference": "TEST-1", "subjects": [ { "id": "p1", "name": "Oleg Deripaska", "kind": "person", "birthYear": 1968 } ] }'
The answer's status is match, and subjects[0].matches names the listed
entries it matched. Each call like this is billed (10p), and counts toward the trial's allowance.
Statuses
Every screening answers with a status for each subject, and one for the whole request.
| Status | Means | What to do |
|---|---|---|
clear | Nothing on the lists resembles the name closely enough to look at. | Go ahead. |
review | A listed name is like this one (a score of 0.7 or more), or something else needs a person's eye: an officer disqualified under sanctions regulations, or a person with significant control whose details Companies House withholds. | Refer it. Someone looks at the matches before going ahead. |
match | The name is a near spelling of a listed name, every word paired (a score of 0.9 or more). | Refer it. Do not go ahead until it has been looked at under your own sanctions procedure. |
notScreened | Only from a company check by name: the company couldn't be identified beyond doubt, so no one was screened. | Refer it. Someone chooses the company and screens it by number. |
- The overall status is
matchif any subject matched, elsereviewif any is to be reviewed (or anyone couldn't be screened), elseclear. - Anything but a 200 answer is not screened. A 4xx or 5xx never means clear.
- A direct sales portal should refer anything but
clear. For a company check by name, refer anything butresolutionmatchedwithstatusclear. - Screening checks; it doesn't decide. A review or a match is for your own compliance judgement.
A birth year that doesn't fit a listed person's cuts the score to 60% of what it was, which clears most common names. Give the birth year and month wherever you have them.
Endpoints
| Call | Does | Calls | Billed |
|---|---|---|---|
POST/api/screen | Screens 1 to 50 people and companies | Sanctions lists Companies House* | Yes |
GET/api/screen/csv | Screens one name; answers CSV | Sanctions lists | Yes |
POST/api/companies/check | Finds a company by name and, if sure, screens it with its people | Companies House Sanctions lists | When matched |
GET/api/companies/check/csv | The same, as CSV | Companies House Sanctions lists | When matched |
GET/api/companies/search | Searches Companies House | Companies House | No |
GET/api/companies/{number} | A company's profile, officers and controllers | Companies House | No |
POST/api/companies/{number}/screen | Screens a company with its people | Companies House Sanctions lists | Yes |
GET/api/companies/{number}/screen/csv | The same, as CSV | Companies House Sanctions lists | Yes |
GET/api/screenings/{id} | A recorded screening | Our records | No |
GET/api/lists | The lists loaded | Our records | No |
GET/health | Whether the service may screen (no key) | Our records | No |
* Companies House only for a company given with its companyNumber.
Screen
POST/api/screen
Calls Sanctions lists Companies House Every name is checked against the sanctions lists. Companies House is called only for a company given with its companyNumber: to fetch its officers and controllers, and, with disqualifications, to check the disqualified directors register.
Screens the people and companies given, and records the screening. A company given with its
companyNumber is looked up at Companies House, and its officers in office and persons with
significant control are screened too.
Request
{
"reference": "Q-10234",
"subjects": [
{ "id": "firm", "name": "Highland Lamb Co Ltd", "kind": "company", "role": "proposer" },
{ "id": "p1", "name": "Oleg Deripaska", "kind": "person", "birthYear": 1968, "birthMonth": 1, "role": "principal" }
]
}
| Field | Rules |
|---|---|
reference | Optional. Your own reference (a quote or proposal number), at most 200 characters. Kept with the record and returned in the answer. |
subjects | 1 to 50. |
subjects[].id | Required, unique within the request. The answer is given by it. |
subjects[].name | Required, at most 300 characters; may be left out for a company given by companyNumber, and the registered name is used. |
subjects[].kind | Required: person or company. |
subjects[].birthYear | Optional, 1900 to this year. Give it wherever you have it. |
subjects[].birthMonth | Optional, 1 to 12, and only with a birthYear. |
subjects[].role | Optional. Their part, such as proposer or principal; returned in the answer. |
subjects[].companyNumber | Optional, for a company only: its Companies House number as Companies House writes it, such as 00445790 or SC296753. |
disqualifications | Optional, true or false (default). With a company given by number, also checks each officer against the disqualified directors register. |
monitor | Optional, for an account whose plan includes re-screening. true keeps watching every subject screened: each is checked again every time a sanctions list changes (Watching names); the answer's watchedUntil says until when. false doesn't. Not given: as your plan says (watched only if it watches everything). Without re-screening on your plan, true is refused with 400. |
monitorMonths | Optional, 1 to 120; 12 if not given. How long to watch them. Watching someone already watched renews the watch. |
With the people Companies House adds, a request may screen at most 300 subjects in all.
Response 200
{
"screeningId": "fb44353e-6b0e-4c8e-9f0a-2d3b5c7e9a10",
"screenedAt": "2026-10-14T17:42:01.123+00:00",
"reference": "Q-10234",
"status": "match",
"lists": [
{ "source": "uk", "title": "…", "loaded": true, "current": true, "generated": "…", "downloadedAt": "…",
"checkedAt": "…", "fingerprint": "067e24…", "parties": 6408 }
],
"thresholds": { "match": 0.9, "review": 0.7 },
"subjects": [
{ "subjectId": "firm", "name": "Highland Lamb Co Ltd", "kind": "company", "role": "proposer", "companyNumber": null,
"status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [], "disqualifications": null },
{ "subjectId": "p1", "name": "Oleg Deripaska", "kind": "person", "role": "principal", "companyNumber": null,
"status": "match", "score": 1, "matchedName": "Oleg DERIPASKA", "datasets": ["uk", "ca"],
"matches": [
{ "source": "uk", "listId": "RUS0269", "caption": "Oleg Vladimirovich DERIPASKA", "matchedName": "Oleg DERIPASKA",
"score": 1, "nameScore": 1, "birthYearRuledOut": false,
"regime": "The Russia (Sanctions) (EU Exit) Regulations 2019" }
],
"disqualifications": null }
],
"companies": [],
"unscreened": [],
"units": { "screenings": 2, "companyChecks": 0, "disqualificationChecks": 0, "billed": 2 },
"verifyCode": "ZNWV-EVQA-BSTV",
"verifyUrl": "https://screening.policy365.cloud/verify?code=ZNWV-EVQA-BSTV"
}
screeningIdfinds the record later, withGET /api/screenings/{id}.unitsis what this call billed, at 10p a unit:screenings(every person and company screened, those Companies House added included) anddisqualificationChecks(every officer checked against the disqualified register) make upbilled;companyChecks, the companies looked up, are counted but not billed. The same as the call's line on your statement.verifyCodeis the record's verification code, andverifyUrlthe link that checks it: give either to anyone who needs to know the record is genuine and unaltered. They aren't part of the signed answer, so they come after it;GET /api/screenings/{id}gives them again.listsis the version of each list screened against, by its SHA-256fingerprint.subjectsare in the order given, each company given by number followed by the subjects Companies House added for it.matchesare the best three listed entries at or above the review threshold, best first; empty when clear.scoreis after the birth year is taken into account,nameScorebefore;birthYearRuledOutsays the birth year cut it.datasetsare the lists the matches came from:uk,eu,un,ca. An EU entry'slistIdis its EU reference, such asEU.3633.76.companiesis what Companies House said about each company given by number (the same shape as the company lookup).unscreenedlists anyone who couldn't be screened, and why. Anything in it makes the screening areviewat least.
With a company number
- Each officer in office and each person with significant control becomes a subject of its own, with ids such as
firm.officer1andfirm.controller1, and the month and year of birth Companies House shows. TheircompanyNumberis the company's. - Each person is screened once, with every role, and checked against the disqualified register once (and billed once): someone Companies House lists as both secretary and director, under one officer id, is one subject, as is someone who is both an officer and a controller.
- If the name you give differs from the registered name, the registered name is screened too, as
firm.registered. - Corporate officers and controllers are screened as companies.
- An officer disqualified under sanctions regulations makes their subject a
revieweven when no list matches. Withdisqualifications, each officer'sdisqualificationslists their entries on the register (from,until,reason,act,underSanctions,companies); otherwise it isnull. - A person with significant control whose details Companies House withholds can't be screened; they are listed in
unscreened. - If Companies House has no such company the answer is
404, and if it can't be reached502. Nothing is screened: the company's name alone is never screened and called clear.
Response with a company number 200: Tesco PLC as { "id": "company", "kind": "company", "companyNumber": "00445790" }, with disqualifications; its officers cut to two here.
{
"screeningId": "5c209f4b-fafd-4efa-8762-c271f3ab9796",
"screenedAt": "2026-10-14T23:26:45.456+00:00",
"reference": "Q-1",
"status": "clear",
"lists": [
{ "source": "ca", "title": "Consolidated Canadian Autonomous Sanctions List", "loaded": true, "current": true,
"generated": null, "downloadedAt": "2026-10-14T17:41:28.22+00:00", "checkedAt": "2026-10-14T17:41:28.22+00:00",
"fingerprint": "cf413b2dee406c209a010d53faeeb9c0ae6c046670f628132c02d16718bf085b", "parties": 5707 },
{ "source": "uk", "title": "…", … },
{ "source": "un", "title": "…", … }
],
"thresholds": { "match": 0.9, "review": 0.7 },
"subjects": [
{ "subjectId": "company", "name": "TESCO PLC", "kind": "company", "role": "company", "companyNumber": null,
"status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [], "disqualifications": null },
{ "subjectId": "company.officer1", "name": "Christopher Jon TAYLOR", "kind": "person", "role": "secretary",
"companyNumber": "00445790", "status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [],
"disqualifications": [] },
{ "subjectId": "company.officer2", "name": "Melissa BETHELL", "kind": "person", "role": "director",
"companyNumber": "00445790", "status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [],
"disqualifications": [] }
],
"companies": [ {
"profile": { "companyNumber": "00445790", "name": "TESCO PLC", "status": "active", "type": "plc",
"created": "1947-11-27", "registeredOffice": "Tesco House, Shire Park, Kestrel Way, Welwyn Garden City, AL7 1GA, United Kingdom",
"sicCodes": ["47110"] },
"officers": [
{ "name": "Christopher Jon TAYLOR", "registeredName": "TAYLOR, Christopher Jon", "role": "secretary", "isCorporate": false,
"appointedOn": "2025-04-14", "birthYear": null, "birthMonth": null, "nationality": null, "occupation": null,
"personNumber": "334718260001", "disqualifications": [] },
{ "name": "Melissa BETHELL", "registeredName": "BETHELL, Melissa", "role": "director", "isCorporate": false,
"appointedOn": "2018-09-24", "birthYear": 1974, "birthMonth": 9, "nationality": "British", "occupation": null,
"personNumber": "248450070003", "disqualifications": [] }
],
"controllers": [
{ "name": "…", "kind": "corporate", "companiesHouseKind": "corporate-entity-person-with-significant-control",
"birthYear": null, "birthMonth": null, "notifiedOn": "2016-04-06", "naturesOfControl": ["ownership-of-shares-75-to-100-percent"] }
],
"disqualificationsChecked": true,
"lookedUpAt": "2026-10-14T23:26:45.123+00:00"
}
],
"unscreened": [],
"units": { "screenings": 3, "companyChecks": 1, "disqualificationChecks": 2, "billed": 5 },
"verifyCode": "K7QM-3XRA-PW9D",
"verifyUrl": "https://screening.policy365.cloud/verify?code=K7QM-3XRA-PW9D"
}
Billed: 10p for each subject screened, those Companies House adds included, and 10p for each officer (a person, not a company) checked against the disqualified register.
Screen, as CSV
GET/api/screen/csv
Calls Sanctions lists One name, checked against the sanctions lists. Companies House isn't called.
Screens one name given in the query, and answers CSV. For MS Access, Excel and anything else that would rather read a line of text than JSON.
GET /api/screen/csv?type=person&name=Oleg%20Deripaska&dob=1968-01-02&reference=ACCESS-1
X-Api-Key: ssk_your_key_here
| Query | Rules |
|---|---|
type | Required: person or company. |
name | Required. URL-encode it as UTF-8. |
dob | Optional: yyyy, yyyy-mm or yyyy-mm-dd. Only the year and month are used. |
reference | Optional, at most 200 characters. Kept with the record and returned last on the row. |
Response 200 text/csv: a header row and one data row, each ending CRLF.
status,score,matchedName,caption,source,listId,regime,birthYearRuledOut,screeningId,screenedAt,reference
match,1,Oleg DERIPASKA,Oleg Vladimirovich DERIPASKA,uk,RUS0269,The Russia (Sanctions) (EU Exit) Regulations 2019,false,a63c1edb-…,2026-10-14T18:12:14Z,ACCESS-1
- A clear row leaves the match columns empty:
clear,0,,,,,,,{screeningId},{screenedAt},{reference}. statusis always the first field and never quoted, so it can be read without a CSV parser: everything before the first comma.- Other fields are quoted only where they need it (a comma, a quote or a line break), with quotes doubled. A Canadian list id such as
"Russia 1, Part 2 item 821"has a comma in it, and so may a reference, so don't split the rest of the row on commas blindly. - The match columns are the best listed entry's. The full record, with the best three, is found by
screeningId. - Failures: see Errors. Anything but a 200 is not screened.
POST /api/screen.
Billed: 10p.
Company check by name
POST/api/companies/check
Calls Companies House Sanctions lists Companies House is searched for the name first. Only if one company is certain are its officers and controllers fetched from Companies House and everyone checked against the sanctions lists.
A company check from a name alone, for underwriting run in the background when all you have is the name the proposer typed.
{ "name": "Highland Lamb Ltd", "reference": "Q-1", "disqualifications": true }
| Field | Rules |
|---|---|
name | Required, 2 to 300 characters, and more than a legal form. |
reference | Optional, at most 200 characters. |
disqualifications | Optional, default false. Checks each officer against the disqualified directors register. |
monitor | Optional, as on POST /api/screen: when the company is matched, true keeps watching it with its officers and controllers (Watching names), and the answer's watchedUntil says until when. Ambiguous or not found, nothing is screened, so nothing is watched: pass monitor again to POST /api/companies/{number}/screen with the company chosen. Not given: as your plan says. |
monitorMonths | Optional, 1 to 120; 12 if not given. |
The service picks the company itself only when there is no doubt: exactly one active company whose name is the name given once case, punctuation, "The", "&"/"and" and the legal form (Ltd, Limited, PLC, LLP, CIC and their Welsh forms) are set aside, and Son/Sons, Brother/Brothers and Farm/Farms are taken as the same word ("J Smith & Son Ltd" is "J SMITH AND SONS LIMITED"), as are initials whether spaced or not ("AM & S A Howie Ltd" is "AM & SA HOWIE LTD"), and a name typed without spaces whose letters are exactly the same ("AABFARMSLTD" is "AAB FARMS LIMITED"). A company found this way is screened by its registered name. That is stricter than the screening's own name matching: "Highland Holdings Ltd" is not "Highland Ltd", and "Highland Lamb Co Ltd" is not "Highland Lamb Ltd".
Response 200, always with a resolution and a status:
| resolution | status | What else comes back |
|---|---|---|
matched | The screening's: clear, review or match | company, and screening: the full screening, as POST /api/screen answers it, recorded the same way. |
ambiguous | notScreened | candidates, best first, each with sameName and a score. |
notFound | notScreened | Nothing: no company with a name at all like it. |
{
"resolution": "ambiguous",
"status": "notScreened",
"name": "Highland Lamb Ltd",
"reference": "Q-1",
"company": null,
"candidates": [
{ "companyNumber": "SC123456", "name": "HIGHLAND LAMB LIMITED", "companyStatus": "dissolved", "type": "ltd",
"created": "2009-03-02", "address": "…", "score": 1, "sameName": true },
{ "companyNumber": "SC654321", "name": "HIGHLAND LAMB CO LTD", "companyStatus": "active", "type": "ltd",
"created": "2015-06-11", "address": "…", "score": 0.88, "sameName": false }
],
"screening": null,
"recordId": "3b0c…",
"units": { "screenings": 0, "companyChecks": 0, "disqualificationChecks": 0, "billed": 0 },
"verifyCode": "4TQX-9MEB-HW2R",
"verifyUrl": "https://screening.policy365.cloud/verify?code=4TQX-9MEB-HW2R"
}
When matched: the screening is the same as POST /api/companies/{number}/screen gives, in full:
{
"resolution": "matched",
"status": "clear",
"name": "Tesco PLC",
"reference": "Q-1",
"company": { "companyNumber": "00445790", "name": "TESCO PLC", "status": "active", "type": "plc", "created": "1947-11-27",
"address": "Tesco House, Shire Park, Kestrel Way, Welwyn Garden City, United Kingdom, AL7 1GA" },
"candidates": [],
"screening": {
"screeningId": "5c209f4b-fafd-4efa-8762-c271f3ab9796",
"screenedAt": "2026-10-14T23:26:45.456+00:00",
"reference": "Q-1",
"status": "clear",
"lists": [
{ "source": "ca", "title": "Consolidated Canadian Autonomous Sanctions List", "loaded": true, "current": true,
"generated": null, "downloadedAt": "2026-10-14T17:41:28.22+00:00", "checkedAt": "2026-10-14T17:41:28.22+00:00",
"fingerprint": "cf413b2dee406c209a010d53faeeb9c0ae6c046670f628132c02d16718bf085b", "parties": 5707 },
{ "source": "uk", "title": "…", … },
{ "source": "un", "title": "…", … }
],
"thresholds": { "match": 0.9, "review": 0.7 },
"subjects": [
{ "subjectId": "company", "name": "TESCO PLC", "kind": "company", "role": "company", "companyNumber": null,
"status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [], "disqualifications": null },
{ "subjectId": "company.officer1", "name": "Christopher Jon TAYLOR", "kind": "person", "role": "secretary",
"companyNumber": "00445790", "status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [],
"disqualifications": [] },
{ "subjectId": "company.officer2", "name": "Melissa BETHELL", "kind": "person", "role": "director",
"companyNumber": "00445790", "status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [],
"disqualifications": [] }
],
"companies": [ {
"profile": { "companyNumber": "00445790", "name": "TESCO PLC", "status": "active", "type": "plc",
"created": "1947-11-27", "registeredOffice": "Tesco House, Shire Park, Kestrel Way, Welwyn Garden City, AL7 1GA, United Kingdom",
"sicCodes": ["47110"] },
"officers": [
{ "name": "Christopher Jon TAYLOR", "registeredName": "TAYLOR, Christopher Jon", "role": "secretary", "isCorporate": false,
"appointedOn": "2025-04-14", "birthYear": null, "birthMonth": null, "nationality": null, "occupation": null,
"personNumber": "334718260001", "disqualifications": [] },
{ "name": "Melissa BETHELL", "registeredName": "BETHELL, Melissa", "role": "director", "isCorporate": false,
"appointedOn": "2018-09-24", "birthYear": 1974, "birthMonth": 9, "nationality": "British", "occupation": null,
"personNumber": "248450070003", "disqualifications": [] }
],
"controllers": [
{ "name": "…", "kind": "corporate", "companiesHouseKind": "corporate-entity-person-with-significant-control",
"birthYear": null, "birthMonth": null, "notifiedOn": "2016-04-06", "naturesOfControl": ["ownership-of-shares-75-to-100-percent"] }
],
"disqualificationsChecked": true,
"lookedUpAt": "2026-10-14T23:26:45.123+00:00"
}
],
"unscreened": []
},
"recordId": "5c209f4b-fafd-4efa-8762-c271f3ab9796",
"units": { "screenings": 3, "companyChecks": 1, "disqualificationChecks": 2, "billed": 5 },
"verifyCode": "K7QM-3XRA-PW9D",
"verifyUrl": "https://screening.policy365.cloud/verify?code=K7QM-3XRA-PW9D"
}
- When
matched,companyholdscompanyNumber,name,status,type,createdandaddress, andcandidatesis empty. - Ambiguous covers a near spelling, a former name, two that fit, and a company with the right name that isn't active (dissolved, in liquidation). Someone then picks a candidate and screens it with
POST /api/companies/{number}/screen, using the same reference. - Companies House answers every search with its nearest companies, however unlike. A candidate is offered only if its name scores 0.6 or more against the name given, or is the very name. If none is left, the answer is
notFound. recordIdfinds the check's record withGET /api/screenings/{id}: the screening's when matched, otherwise the check's own. A referral is recorded too, andverifyCodeandverifyUrlare that record's.unitsis what the call billed: nothing unlessmatched. Thescreeninginside is the answer as recorded, so the units and code are given once, at the top.- Reading only
statusnever mistakes "we couldn't tell which company" for clear. - Failures are
400,502and503, not an answer.
Billed: when matched, as a company screened by number (10p a subject, 10p an officer checked for disqualification). Ambiguous and not found are free.
Company check, as CSV
GET/api/companies/check/csv
Calls Companies House Sanctions lists As the company check by name: Companies House first, then the sanctions lists if the company is certain.
The same check, answered as CSV.
GET /api/companies/check/csv?name=Highland%20Lamb%20Ltd&reference=Q-1&disqualifications=true
monitor=true and monitorMonths may be added, as in the JSON.
Response 200 text/csv when matched: the company, then its people.
status,resolution,name,role,kind,score,matchedName,caption,source,listId,regime,birthYearRuledOut,disqualified,companyNumber,companyStatus,address,screeningId,screenedAt,reference
clear,matched,Tesco PLC,company,company,0,,,,,,,,00445790,active,"Tesco House, Shire Park, Kestrel Way, Welwyn Garden City, United Kingdom, AL7 1GA",5c209f4b-…,2026-10-14T23:26:45Z,Q-1
clear,matched,Christopher Jon TAYLOR,secretary,person,0,,,,,,,,00445790,active,"Tesco House, Shire Park, Kestrel Way, Welwyn Garden City, United Kingdom, AL7 1GA",5c209f4b-…,2026-10-14T23:26:45Z,Q-1
When ambiguous: a choose row for each candidate, nothing screened.
status,resolution,name,role,kind,score,matchedName,caption,source,listId,regime,birthYearRuledOut,disqualified,companyNumber,companyStatus,address,screeningId,screenedAt,reference
choose,ambiguous,HIGHLAND LAMB LIMITED,candidate,company,1,,,,,,,,SC296753,dissolved,"Baird Avenue, Dryburgh Industrial Estate, Dundee, DD2 3TN",,,Q-4
When not found: one row.
status,resolution,name,role,kind,score,matchedName,caption,source,listId,regime,birthYearRuledOut,disqualified,companyNumber,companyStatus,address,screeningId,screenedAt,reference
notScreened,notFound,Zzqx Widgets Ltd,,company,,,,,,,,,,,,,,Q-5
- Matched: a row for each subject screened, the company first, then its officers and controllers; and a
reviewrow named(not screened)for anyone who couldn't be screened. The worst status on any row is the check's. - Ambiguous: a
chooserow for each candidate, with itscompanyNumber,companyStatus,addressandscore. - Not found: one
notScreenedrow with the name given. disqualifiedistrueorfalsefor an officer checked against the register, and empty otherwise.statusis first and never quoted.
Billed: as POST /api/companies/check.
Company search
GET/api/companies/search
Calls Companies House Companies House's search only. No one is checked against the sanctions lists.
Searches Companies House. Nothing is screened or recorded.
GET /api/companies/search?q=tesco
q is a name, part of one, or a company number: two characters at least.
[
{ "companyNumber": "00445790", "name": "TESCO PLC", "status": "active", "type": "plc",
"created": "1947-11-27", "address": "Tesco House, Shire Park, Kestrel Way, Welwyn Garden City, AL7 1GA" }
]
Billed: no.
Company lookup
GET/api/companies/{number}
Calls Companies House Companies House only: the profile, officers and controllers, and, with disqualifications, the disqualified directors register. No one is checked against the sanctions lists.
A company's profile, its officers in office and its persons with significant control still notified, each with the month and year of birth Companies House shows. Former officers and ceased controllers are left out. Nothing is screened or recorded.
GET /api/companies/00445790?disqualifications=true
disqualifications=true adds each officer's entries on the disqualified directors register. It is
optional because it costs Companies House a search and up to three fetches for each officer.
{
"profile": { "companyNumber": "00445790", "name": "TESCO PLC", "status": "active", "type": "plc",
"created": "1947-11-27", "registeredOffice": "Tesco House, Shire Park, Kestrel Way, Welwyn Garden City, AL7 1GA, United Kingdom",
"sicCodes": ["47110"] },
"officers": [
{ "name": "Christopher Jon TAYLOR", "registeredName": "TAYLOR, Christopher Jon", "role": "secretary", "isCorporate": false,
"appointedOn": "2025-04-14", "birthYear": null, "birthMonth": null, "nationality": null, "occupation": null,
"personNumber": "334718260001", "disqualifications": [] },
{ "name": "Melissa BETHELL", "registeredName": "BETHELL, Melissa", "role": "director", "isCorporate": false,
"appointedOn": "2018-09-24", "birthYear": 1974, "birthMonth": 9, "nationality": "British", "occupation": null,
"personNumber": "248450070003", "disqualifications": [] }
],
"controllers": [
{ "name": "…", "kind": "corporate", "companiesHouseKind": "corporate-entity-person-with-significant-control",
"birthYear": null, "birthMonth": null, "notifiedOn": "2016-04-06", "naturesOfControl": ["ownership-of-shares-75-to-100-percent"] }
],
"disqualificationsChecked": true,
"lookedUpAt": "2026-10-14T23:26:45.123+00:00"
}
- A controller's
kindisindividual,corporate,legalPersonorwithheld. A withheld controller has no name. - An officer's
disqualificationsisnullwhen not checked. 404if Companies House has no such company;502if it can't answer.
Billed: no, with or without disqualifications.
Company screen by number
POST/api/companies/{number}/screen
Calls Companies House Sanctions lists Companies House for the company's officers and controllers (and the disqualified directors register, if asked), then everyone against the sanctions lists.
Screens a company with its officers in office and persons with significant control, as one recorded screening.
The same as POST /api/screen with one company given by companyNumber.
POST /api/companies/00445790/screen
Content-Type: application/json
{ "reference": "Q-1", "disqualifications": true, "monitor": true }
The body is optional, and so is every field: reference, disqualifications, and monitor and monitorMonths as on POST /api/screen, watching the company with its officers and controllers. The company is subject company and its people company.officer1, company.controller1 and so on.
Response 200, the same shape as POST /api/screen's (its officers cut to two here):
{
"screeningId": "5c209f4b-fafd-4efa-8762-c271f3ab9796",
"screenedAt": "2026-10-14T23:26:45.456+00:00",
"reference": "Q-1",
"status": "clear",
"lists": [
{ "source": "ca", "title": "Consolidated Canadian Autonomous Sanctions List", "loaded": true, "current": true,
"generated": null, "downloadedAt": "2026-10-14T17:41:28.22+00:00", "checkedAt": "2026-10-14T17:41:28.22+00:00",
"fingerprint": "cf413b2dee406c209a010d53faeeb9c0ae6c046670f628132c02d16718bf085b", "parties": 5707 },
{ "source": "uk", "title": "…", … },
{ "source": "un", "title": "…", … }
],
"thresholds": { "match": 0.9, "review": 0.7 },
"subjects": [
{ "subjectId": "company", "name": "TESCO PLC", "kind": "company", "role": "company", "companyNumber": null,
"status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [], "disqualifications": null },
{ "subjectId": "company.officer1", "name": "Christopher Jon TAYLOR", "kind": "person", "role": "secretary",
"companyNumber": "00445790", "status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [],
"disqualifications": [] },
{ "subjectId": "company.officer2", "name": "Melissa BETHELL", "kind": "person", "role": "director",
"companyNumber": "00445790", "status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [],
"disqualifications": [] }
],
"companies": [ {
"profile": { "companyNumber": "00445790", "name": "TESCO PLC", "status": "active", "type": "plc",
"created": "1947-11-27", "registeredOffice": "Tesco House, Shire Park, Kestrel Way, Welwyn Garden City, AL7 1GA, United Kingdom",
"sicCodes": ["47110"] },
"officers": [
{ "name": "Christopher Jon TAYLOR", "registeredName": "TAYLOR, Christopher Jon", "role": "secretary", "isCorporate": false,
"appointedOn": "2025-04-14", "birthYear": null, "birthMonth": null, "nationality": null, "occupation": null,
"personNumber": "334718260001", "disqualifications": [] },
{ "name": "Melissa BETHELL", "registeredName": "BETHELL, Melissa", "role": "director", "isCorporate": false,
"appointedOn": "2018-09-24", "birthYear": 1974, "birthMonth": 9, "nationality": "British", "occupation": null,
"personNumber": "248450070003", "disqualifications": [] }
],
"controllers": [
{ "name": "…", "kind": "corporate", "companiesHouseKind": "corporate-entity-person-with-significant-control",
"birthYear": null, "birthMonth": null, "notifiedOn": "2016-04-06", "naturesOfControl": ["ownership-of-shares-75-to-100-percent"] }
],
"disqualificationsChecked": true,
"lookedUpAt": "2026-10-14T23:26:45.123+00:00"
}
],
"unscreened": [],
"units": { "screenings": 3, "companyChecks": 1, "disqualificationChecks": 2, "billed": 5 },
"verifyCode": "K7QM-3XRA-PW9D",
"verifyUrl": "https://screening.policy365.cloud/verify?code=K7QM-3XRA-PW9D"
}
Billed: 10p for each subject screened, and 10p for each officer checked against the disqualified register.
Company screen, as CSV
GET/api/companies/{number}/screen/csv
Calls Companies House Sanctions lists As the company screen by number.
The same screening, answered as CSV: one row per subject, the company first.
GET /api/companies/00445790/screen/csv?reference=T-1&disqualifications=true
monitor=true and monitorMonths may be added, as in the JSON.
status,name,role,kind,score,matchedName,caption,source,listId,regime,birthYearRuledOut,disqualified,companyNumber,screeningId,screenedAt,reference
clear,TESCO PLC,company,company,0,,,,,,,,00445790,e40de257-…,2026-10-14T18:44:52Z,T-1
clear,Melissa BETHELL,director,person,0,,,,,,,false,00445790,e40de257-…,2026-10-14T18:44:52Z,T-1
- Anyone who couldn't be screened gets a
reviewrow named(not screened). - The worst status on any row is the screening's.
disqualifiedistrueorfalsefor an officer checked against the register, and empty otherwise.statusis first and never quoted.
Billed: as POST /api/companies/{number}/screen.
Watching names
GET/api/monitor DELETE/api/monitor/{watchId}
Calls Our records Your account's watches. Neither Companies House nor the lists are called.
Re-screening is part of an account's plan: ask us to add it. With it, screen with "monitor": true and every subject screened is watched, 12 months unless you say otherwise: each time a
sanctions list changes, everyone watched is checked against what changed. If one starts to resemble a listed entry, they're
screened again in full as a screening of your own, recorded and signed as usual, with your decisions applied (a
notThem holds while the entry it was about is unchanged). If it's still a review or match, it's
under Needs a decision on the site, and your account's owner and reviewers are emailed: how many, and a link, never the names.
These re-checks aren't billed by the name. With a webhook, your system is told too: monitor.matched.
GET /api/monitor: the watches running now, newest first, and how many (live); with?all=true, ended and lapsed ones too. Each has itswatchId, the name and birth as screened, yourreference,fromScreeningId,endsAt,lastCheckedAtandlastHitScreeningId.DELETE /api/monitor/{watchId}: stop watching.204, or404if it isn't a running watch of yours. An ended watch is kept six years, like the records.- Screening the same person again with
monitorrenews their watch. A watch nobody renews lapses atendsAt.
Billed: no.
Webhooks
GET/api/webhooks POST/api/webhooks DELETE/api/webhooks/{id} POST/api/webhooks/{id}/test
Calls Our records Your systems, at the addresses you give.
Your systems told when something happens, rather than asking. Add up to five https addresses (on the site, under Account, or
POST /api/webhooks with { "url": "https://…", "events": ["monitor.matched", "decision.recorded"] }); the
answer, 201, gives the address's secret that once. Every event to it is signed with that secret.
| Event | When | data |
|---|---|---|
monitor.matched | A re-check, when a list changed, found someone you watch resembling a new or changed entry: still a review or match after your decisions. | screeningId, watchId, status, reference, list (source, generated, fingerprint), verifyUrl |
decision.recorded | Someone on your account, or your system, recorded a decision. | decisionId, screeningId, subjectId, source, listId, verdict, decidedAt |
webhook.test | Sent on demand, to see an address works. | message |
POST https://your-system.example.com/screening/events
webhook-id: evt_6f0b2c…
webhook-timestamp: 1792112400
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Content-Type: application/json
{ "id": "evt_6f0b2c…", "type": "monitor.matched", "time": "2026-10-16T09:40:00Z", "source": "https://screening.policy365.cloud",
"data": { "screeningId": "fb44353e-…", "watchId": 1042, "status": "review", "reference": "POL-1234",
"list": { "source": "uk", "generated": "2026-10-16T08:00:00Z", "fingerprint": "067e24…" },
"verifyUrl": "https://screening.policy365.cloud/verify?code=K7QM-3XRA-PW9D" } }
- Thin: ids and status, never the names screened. Fetch the rest with your key,
GET /api/screenings/{screeningId}. - Check the signature before trusting one: it's the Standard Webhooks scheme, so their library for your language does it in a line. Refuse one whose
webhook-timestampis more than five minutes off. - Answer
2xxwithin 15 seconds. Anything else, or no answer, is tried again after 1 minute, 5, 30, 2 hours, 6 and 12, then given up; redirects aren't followed. The same event keeps itswebhook-idon every try, so ignore one you've had. GET /api/webhooks/deliveriesshows the last 50 deliveries and how each went, andPOST /api/webhooks/deliveries/{id}/resendsends one again.- The OpenAPI document's
webhookssection describes each event's body in full.
Checking a signature, with Standard Webhooks' libraries:
// C#: dotnet add package StandardWebhooks
var hook = new StandardWebhooks.StandardWebhook(secret); // "whsec_…", as you were given it
hook.Verify(body, request.Headers); // throws if it isn't ours, or is stale
# Python: pip install standardwebhooks
from standardwebhooks.webhooks import Webhook
Webhook(secret).verify(body, headers)
// Node: npm install standardwebhooks
import { Webhook } from "standardwebhooks";
new Webhook(secret).verify(body, headers);
Billed: no.
Screening records
GET/api/screenings/{id}
Calls Our records A screening already made, read back from our records. Neither Companies House nor the sanctions lists are called.
A recorded screening, or a company check by name that screened no one: who asked, when, your reference, which
list versions were used, the request and the answer. {id} is a screeningId or
recordId.
request is the request as made, and result the answer as given, in full: here, the company screen by number above. units, verifyCode and verifyUrl are as the call gave them.
{
"screeningId": "5c209f4b-fafd-4efa-8762-c271f3ab9796",
"screenedAt": "2026-10-14T23:26:45.456+00:00",
"caller": "your-client-name",
"reference": "Q-1",
"status": "clear",
"subjectCount": 3,
"lists": [ { "source": "ca", "title": "…", "fingerprint": "cf413b…", … }, … ],
"request": {
"reference": "Q-1",
"subjects": [
{ "id": "company", "name": null, "kind": "company", "birthYear": null, "birthMonth": null, "role": "company",
"companyNumber": "00445790" }
],
"disqualifications": true
},
"result": {
"screeningId": "5c209f4b-fafd-4efa-8762-c271f3ab9796",
"screenedAt": "2026-10-14T23:26:45.456+00:00",
"reference": "Q-1",
"status": "clear",
"lists": [
{ "source": "ca", "title": "Consolidated Canadian Autonomous Sanctions List", "loaded": true, "current": true,
"generated": null, "downloadedAt": "2026-10-14T17:41:28.22+00:00", "checkedAt": "2026-10-14T17:41:28.22+00:00",
"fingerprint": "cf413b2dee406c209a010d53faeeb9c0ae6c046670f628132c02d16718bf085b", "parties": 5707 },
{ "source": "uk", "title": "…", … },
{ "source": "un", "title": "…", … }
],
"thresholds": { "match": 0.9, "review": 0.7 },
"subjects": [
{ "subjectId": "company", "name": "TESCO PLC", "kind": "company", "role": "company", "companyNumber": null,
"status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [], "disqualifications": null },
{ "subjectId": "company.officer1", "name": "Christopher Jon TAYLOR", "kind": "person", "role": "secretary",
"companyNumber": "00445790", "status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [],
"disqualifications": [] },
{ "subjectId": "company.officer2", "name": "Melissa BETHELL", "kind": "person", "role": "director",
"companyNumber": "00445790", "status": "clear", "score": 0, "matchedName": null, "datasets": [], "matches": [],
"disqualifications": [] }
],
"companies": [ {
"profile": { "companyNumber": "00445790", "name": "TESCO PLC", "status": "active", "type": "plc",
"created": "1947-11-27", "registeredOffice": "Tesco House, Shire Park, Kestrel Way, Welwyn Garden City, AL7 1GA, United Kingdom",
"sicCodes": ["47110"] },
"officers": [
{ "name": "Christopher Jon TAYLOR", "registeredName": "TAYLOR, Christopher Jon", "role": "secretary", "isCorporate": false,
"appointedOn": "2025-04-14", "birthYear": null, "birthMonth": null, "nationality": null, "occupation": null,
"personNumber": "334718260001", "disqualifications": [] },
{ "name": "Melissa BETHELL", "registeredName": "BETHELL, Melissa", "role": "director", "isCorporate": false,
"appointedOn": "2018-09-24", "birthYear": 1974, "birthMonth": 9, "nationality": "British", "occupation": null,
"personNumber": "248450070003", "disqualifications": [] }
],
"controllers": [
{ "name": "…", "kind": "corporate", "companiesHouseKind": "corporate-entity-person-with-significant-control",
"birthYear": null, "birthMonth": null, "notifiedOn": "2016-04-06", "naturesOfControl": ["ownership-of-shares-75-to-100-percent"] }
],
"disqualificationsChecked": true,
"lookedUpAt": "2026-10-14T23:26:45.123+00:00"
}
],
"unscreened": []
},
"units": { "screenings": 3, "companyChecks": 1, "disqualificationChecks": 2, "billed": 5 },
"verifyCode": "K7QM-3XRA-PW9D",
"verifyUrl": "https://screening.policy365.cloud/verify?code=K7QM-3XRA-PW9D"
}
404 if there is no such record. Billed: no.
Decisions on reviews
POST/api/screenings/{id}/decisions GET/api/screenings/{id}/decisions
Calls Our records Recorded against your account. Neither Companies House nor the sanctions lists are called.
Records whether a subject of a screening is the listed entry it resembled. The decision is remembered for that person (their name, date of birth and company number, as screened) against that entry as it stood, and every later screening of them by your account takes it into account:
notThem: that entry no longer raises a review for them. A subject whose every match is decidednotThemcomes backclear. If the entry changes (a new alias or date of birth), it raises a review again.isThem: that entry is amatchfor them whatever its score.withdrawn: undoes the last decision about that entry. Decisions are never changed or deleted; a new one replaces the last.
POST /api/screenings/fb44353e-…/decisions
Content-Type: application/json
{ "subjectId": "p1", "source": "eu", "listId": "EU.1042.29", "verdict": "notThem",
"reason": "Born 1985 per passport; the listed person is older", "decidedBy": "Jane Smith, underwriting" }
| Field | Rules |
|---|---|
subjectId | Required: the subject as the screening's answer named it (p1, firm.officer2). |
source, listId | Required: the entry, as one of that subject's matches gave it. |
verdict | Required: notThem, isThem or withdrawn. |
reason | Required, up to 1,000 characters: shown whenever the decision is applied, and on the record. |
decidedBy | Required: the person who decided. A key isn't a person; it's recorded with the key it came through. |
Response 201: the decision, with its decisionId and decidedAt. 400 says what's wrong; 404 if the screening isn't yours.
In every answer, a subject's decisions lists the earlier decisions its status took into account: each with its verdict, reason, decidedBy, decidedAt, and holds, false when the entry has changed since. Each match carries its entry's entryFingerprint; a subject carries the birthYear and birthMonth it was screened with.
Billed: no.
Lists and health
GET/api/lists and GET/health
Calls Our records The state of the sanctions lists this service holds. Nothing outside is called.
/api/lists says what's loaded and when each list was last fetched:
{
"ready": true,
"parties": 13125,
"lists": [
{ "source": "uk", "title": "…", "loaded": true, "current": true, "generated": "…", "downloadedAt": "…",
"checkedAt": "…", "fingerprint": "067e24…", "parties": 6408 },
{ "source": "un", … },
{ "source": "ca", … }
]
}
Each list is fetched daily. If any list hasn't been fetched successfully for 48 hours, ready is
false and the service refuses to screen (503) rather than answer clear from old lists.
/health needs no key. It answers 200 { "ready": true } when the service may
screen, and 503 { "ready": false, "stale": ["un"] } when it may not. It is for a
monitor or a load balancer.
Billed: no.
Errors
Anything but a 200 is not screened, and is never billed. JSON endpoints answer
errors as problem details (application/problem+json).
| Status | Means | What to do |
|---|---|---|
400 | A wrong request. Every problem is listed. | Fix the request. |
401 | No live key in X-Api-Key: missing, wrong, revoked or expired, or the account is closed. | Check the key, or issue a new one from your account. |
402 | The trial's allowance of 500 units is used. Screening starts again when the trial ends and billing begins. Searches, lookups and records still work. | Screening resumes when the trial ends; the detail gives the date. |
404 | Companies House has no company by that number, or there is no such screening record. | Check the number or id. |
429 | Past your rate limit: 120 calls a minute unless agreed otherwise. | Wait the number of seconds in Retry-After, then try again. |
502 | Companies House couldn't be reached, or refused. | Try again later. Nothing was screened. |
503 | Not screened: the sanctions lists aren't all loaded and current, the screening couldn't be recorded, or Companies House's limit for this service is reached for now. We pace our calls to Companies House, so a call waits up to 20 seconds for room before it's refused. | Treat as not screened. With Retry-After (Companies House's limit), wait that many seconds, then try again; otherwise try again later. |
A 400 from POST /api/screen:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"request": [ "subjects[0].name: needed", "subjects[1].birthMonth: only with a birthYear" ]
}
}
JSON that can't be read at all is reported against its path, such as $.subjects[0].birthYear.
A 503, 502 or 404 from a screening:
{
"status": 503,
"title": "Not screened",
"detail": "The sanctions lists aren't all loaded and current",
"lists": [ … ]
}
The CSV endpoints answer a 400, 404, 502 or
503 as one line of plain text saying why, such as
Not screened: The sanctions lists aren't all loaded and current. A 401,
402 or 429 is refused before the endpoint is reached, and comes back as JSON problem
details as above. Either way, check the HTTP status first: anything but 200 is not screened.
Pricing and billing
Pay as you go: no tie-in, no contract term, no minimum spend and no set-up fee. You pay for what you use, monthly, and can cancel any time from your account.
| What | Price |
|---|---|
| Each name screened: a person or company you give, or one Companies House adds (officers, controllers, a registered name that differs) | 10p |
| Each officer checked against the disqualified directors register | 10p |
| Company check by name that is ambiguous or not found | Free |
| Company search and lookup; screening records; lists; health | Free |
| Any call that fails (400, 401, 402, 404, 429, 502, 503) | Free |
- Prices exclude VAT.
- Billed monthly in arrears through Stripe, for what was used in the month.
- A 14-day free trial. A card is taken at sign-up and nothing is charged during the trial. The trial is capped at 500 units (names screened plus disqualification checks); past that, billed calls are refused with
402until the trial ends. - Cancel at any time from the billing portal, reached from your account.
- A company screened by number costs one unit for the company, one for each officer and controller, and, with
disqualifications, one for each officer who is a person. A company with 6 directors and 2 controllers, one of them also a director, is 8 names: 80p, or £1.40 with the disqualification check.
Examples
Each example is complete and runs as written. Set the environment variable SANCTIONS_API_KEY to
your key first (in PowerShell, $env:SANCTIONS_API_KEY = "ssk_…"; in bash,
export SANCTIONS_API_KEY=ssk_…), so the key stays out of your source. The VBA examples hold it in a
constant, ssk_your_key_here, to be replaced; each VBA example is a whole standard module, to be
pasted into a module of its own. Choosing a language shows it in every example.
Screen two people
Two people screened in one request with POST /api/screen, with the overall status and each person's.
// Program.cs in a console app (dotnet new console), .NET 8 or later.
// The key is read from the SANCTIONS_API_KEY environment variable, not written in the code.
using System.Net.Http.Json;
var key = Environment.GetEnvironmentVariable("SANCTIONS_API_KEY")
?? throw new InvalidOperationException("Set SANCTIONS_API_KEY to your key");
using var http = new HttpClient { BaseAddress = new Uri("https://screening.policy365.cloud/") };
http.DefaultRequestHeaders.Add("X-Api-Key", key);
var request = new
{
reference = "Q-10234",
subjects = new object[]
{
new { id = "p1", name = "Oleg Deripaska", kind = "person", birthYear = 1968, birthMonth = 1, role = "principal" },
new { id = "p2", name = "Jane Smith", kind = "person", birthYear = 1980, role = "director" },
},
};
using var response = await http.PostAsJsonAsync("api/screen", request);
if (!response.IsSuccessStatusCode)
{
// anything but 200 is not screened: refer the case, or try again later
Console.WriteLine($"Not screened: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return;
}
var answer = await response.Content.ReadFromJsonAsync<ScreenAnswer>();
Console.WriteLine($"{answer!.Status} (screening {answer.ScreeningId})");
foreach (var s in answer.Subjects)
Console.WriteLine($" {s.SubjectId}: {s.Status} {s.Score} {s.MatchedName}");
record ScreenAnswer(Guid ScreeningId, string Status, List<SubjectAnswer> Subjects);
record SubjectAnswer(string SubjectId, string Status, double Score, string? MatchedName);
' A standard module of its own, in Access or Excel. Nothing to add under Tools > References.
Option Explicit
Private Const API_BASE As String = "https://screening.policy365.cloud"
Private Const API_KEY As String = "ssk_your_key_here" ' better kept in a table or a settings sheet than in code
Public Sub ScreenTwoPeople()
Dim http As Object, body As String, json As String, pos As Long, id As String
body = "{""reference"":""Q-10234"",""subjects"":[" & _
PersonJson("p1", "Oleg Deripaska", 1968, 1, "principal") & "," & _
PersonJson("p2", "Jane Smith", 1980, 0, "director") & "]}"
On Error GoTo Failed
Set http = CreateObject("MSXML2.ServerXMLHTTP.6.0")
http.Open "POST", API_BASE & "/api/screen", False
http.setRequestHeader "Content-Type", "application/json"
http.setRequestHeader "X-Api-Key", API_KEY
http.send body
On Error GoTo 0
If http.Status <> 200 Then
' anything but 200 is not screened
Debug.Print "Not screened: " & http.Status & " " & http.responseText
Exit Sub
End If
json = http.responseText
Debug.Print "Overall: " & JsonString(json, "status") & ", screening " & JsonString(json, "screeningId")
pos = 1
Do
id = JsonString(json, "subjectId", pos)
If pos = 0 Then Exit Do
Debug.Print " " & id & ": " & JsonString(json, "status", pos)
Loop
Exit Sub
Failed:
Debug.Print "Not screened: " & Err.Description
End Sub
' One person as JSON; a birth year or month of 0 is left out.
Private Function PersonJson(id As String, fullName As String, birthYear As Long, birthMonth As Long, role As String) As String
Dim s As String
s = "{""id"":" & JsonText(id) & ",""name"":" & JsonText(fullName) & ",""kind"":""person"""
If birthYear > 0 Then s = s & ",""birthYear"":" & birthYear
If birthMonth > 0 Then s = s & ",""birthMonth"":" & birthMonth
PersonJson = s & ",""role"":" & JsonText(role) & "}"
End Function
' A JSON string, quoted. Anything outside plain ASCII is written as \uXXXX, so accented names arrive intact.
Private Function JsonText(text As String) As String
Dim i As Long, c As Long, out As String
For i = 1 To Len(text)
c = AscW(Mid$(text, i, 1)) And &HFFFF&
Select Case c
Case 34: out = out & "\"""
Case 92: out = out & "\\"
Case 32 To 126: out = out & ChrW$(c)
Case Else: out = out & "\u" & Right$("000" & Hex$(c), 4)
End Select
Next
JsonText = """" & out & """"
End Function
' The string value of the first "key" at or after position start, unescaped; "" where it is null or absent.
' start is moved past the value, or set to 0 when the key isn't found, so a loop can walk through the answer.
Private Function JsonString(json As String, key As String, Optional ByRef start As Long = 1) As String
Dim i As Long, ch As String, out As String
If start < 1 Then Exit Function
i = InStr(start, json, """" & key & """:", vbBinaryCompare)
If i = 0 Then
start = 0
Exit Function
End If
i = i + Len(key) + 3
If Mid$(json, i, 1) <> """" Then ' null, a number or true/false
start = i
Exit Function
End If
i = i + 1
Do While i <= Len(json)
ch = Mid$(json, i, 1)
If ch = """" Then Exit Do
If ch = "\" Then
i = i + 1
ch = Mid$(json, i, 1)
Select Case ch
Case "n": ch = vbLf
Case "r": ch = vbCr
Case "t": ch = vbTab
Case "b": ch = Chr$(8)
Case "f": ch = Chr$(12)
Case "u"
ch = ChrW$(CLng("&H" & Mid$(json, i + 1, 4)))
i = i + 4
End Select
End If
out = out & ch
i = i + 1
Loop
start = i + 1
JsonString = out
End Function
# bash or zsh (on Windows: Git Bash or WSL). The key is read from SANCTIONS_API_KEY.
curl -sS https://screening.policy365.cloud/api/screen \
-H "X-Api-Key: $SANCTIONS_API_KEY" \
-H "Content-Type: application/json" \
-w '\nHTTP %{http_code}\n' \
-d '{
"reference": "Q-10234",
"subjects": [
{ "id": "p1", "name": "Oleg Deripaska", "kind": "person", "birthYear": 1968, "birthMonth": 1, "role": "principal" },
{ "id": "p2", "name": "Jane Smith", "kind": "person", "birthYear": 1980, "role": "director" }
]
}'
# pip install requests. The key is read from the SANCTIONS_API_KEY environment variable.
import os
import requests
BASE = "https://screening.policy365.cloud"
KEY = os.environ["SANCTIONS_API_KEY"]
response = requests.post(
f"{BASE}/api/screen",
headers={"X-Api-Key": KEY},
json={
"reference": "Q-10234",
"subjects": [
{"id": "p1", "name": "Oleg Deripaska", "kind": "person", "birthYear": 1968, "birthMonth": 1, "role": "principal"},
{"id": "p2", "name": "Jane Smith", "kind": "person", "birthYear": 1980, "role": "director"},
],
},
timeout=60,
)
if response.status_code != 200:
# anything but 200 is not screened
raise SystemExit(f"Not screened: {response.status_code} {response.text}")
answer = response.json()
print(answer["status"], answer["screeningId"])
for s in answer["subjects"]:
print(f' {s["subjectId"]}: {s["status"]} {s["score"]} {s["matchedName"] or ""}')
# Windows PowerShell 5.1 or PowerShell 7. The key is read from the SANCTIONS_API_KEY environment variable.
if (-not $env:SANCTIONS_API_KEY) { throw "Set SANCTIONS_API_KEY to your key" }
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # for Windows PowerShell 5.1
$base = "https://screening.policy365.cloud"
$headers = @{ "X-Api-Key" = $env:SANCTIONS_API_KEY }
$body = @{
reference = "Q-10234"
subjects = @(
@{ id = "p1"; name = "Oleg Deripaska"; kind = "person"; birthYear = 1968; birthMonth = 1; role = "principal" }
@{ id = "p2"; name = "Jane Smith"; kind = "person"; birthYear = 1980; role = "director" }
)
} | ConvertTo-Json -Depth 5
try {
$answer = Invoke-RestMethod -Method Post -Uri "$base/api/screen" -Headers $headers `
-ContentType "application/json; charset=utf-8" -Body ([Text.Encoding]::UTF8.GetBytes($body))
}
catch {
# anything but 200 is not screened
Write-Error "Not screened: $([int]$_.Exception.Response.StatusCode) $($_.ErrorDetails.Message)"
return
}
"$($answer.status) (screening $($answer.screeningId))"
foreach ($s in $answer.subjects) { " $($s.subjectId): $($s.status) $($s.score) $($s.matchedName)" }
// screen.mjs, run with: node screen.mjs (Node 18 or later; fetch is built in).
// The key is read from the SANCTIONS_API_KEY environment variable.
const BASE = "https://screening.policy365.cloud";
const KEY = process.env.SANCTIONS_API_KEY;
if (!KEY) throw new Error("Set SANCTIONS_API_KEY to your key");
const response = await fetch(`${BASE}/api/screen`, {
method: "POST",
headers: { "X-Api-Key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({
reference: "Q-10234",
subjects: [
{ id: "p1", name: "Oleg Deripaska", kind: "person", birthYear: 1968, birthMonth: 1, role: "principal" },
{ id: "p2", name: "Jane Smith", kind: "person", birthYear: 1980, role: "director" },
],
}),
});
if (!response.ok) {
// anything but 200 is not screened
console.error(`Not screened: ${response.status} ${await response.text()}`);
process.exit(1);
}
const answer = await response.json();
console.log(`${answer.status} (screening ${answer.screeningId})`);
for (const s of answer.subjects) {
console.log(` ${s.subjectId}: ${s.status} ${s.score} ${s.matchedName ?? ""}`);
}
Check a company by name
POST /api/companies/check with the name the proposer typed, handling each
resolution: go ahead only on matched with clear; refer everything else.
// Program.cs in a console app (dotnet new console), .NET 8 or later.
// The key is read from the SANCTIONS_API_KEY environment variable, not written in the code.
using System.Net.Http.Json;
var key = Environment.GetEnvironmentVariable("SANCTIONS_API_KEY")
?? throw new InvalidOperationException("Set SANCTIONS_API_KEY to your key");
using var http = new HttpClient { BaseAddress = new Uri("https://screening.policy365.cloud/") };
http.DefaultRequestHeaders.Add("X-Api-Key", key);
using var response = await http.PostAsJsonAsync("api/companies/check",
new { name = "Highland Lamb Ltd", reference = "Q-1", disqualifications = true });
if (!response.IsSuccessStatusCode)
{
// 400, 502 or 503: not screened
Console.WriteLine($"Not screened: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return;
}
var check = await response.Content.ReadFromJsonAsync<CheckAnswer>();
switch (check!.Resolution)
{
case "matched":
Console.WriteLine($"{check.Company!.Name} ({check.Company.CompanyNumber}): {check.Status}");
if (check.Status != "clear")
Console.WriteLine("Refer: someone must look at this before going ahead.");
break;
case "ambiguous":
Console.WriteLine("Not screened. Choose the company, then screen it with POST /api/companies/{number}/screen:");
foreach (var c in check.Candidates)
Console.WriteLine($" {c.CompanyNumber} {c.Name} ({c.CompanyStatus}) score {c.Score}{(c.SameName ? ", same name" : "")}");
break;
default: // notFound
Console.WriteLine("Not screened: Companies House has no company with a name like this. Refer.");
break;
}
record CheckAnswer(string Resolution, string Status, Company? Company, List<Candidate> Candidates, Guid RecordId);
record Company(string CompanyNumber, string Name, string? Status);
record Candidate(string CompanyNumber, string Name, string? CompanyStatus, double Score, bool SameName);
' A standard module of its own, in Access or Excel. Nothing to add under Tools > References.
Option Explicit
Private Const API_BASE As String = "https://screening.policy365.cloud"
Private Const API_KEY As String = "ssk_your_key_here" ' better kept in a table or a settings sheet than in code
' "clear" when the company was found beyond doubt and screened clear; anything else begins "refer:".
' For example: Debug.Print CheckCompany("Highland Lamb Ltd", "Q-1")
Public Function CheckCompany(companyName As String, reference As String) As String
Dim http As Object, json As String, resolution As String, result As String, pos As Long, number As String, choices As String
On Error GoTo Failed
Set http = CreateObject("MSXML2.ServerXMLHTTP.6.0")
http.Open "POST", API_BASE & "/api/companies/check", False
http.setRequestHeader "Content-Type", "application/json"
http.setRequestHeader "X-Api-Key", API_KEY
http.send "{""name"":" & JsonText(companyName) & ",""reference"":" & JsonText(reference) & ",""disqualifications"":true}"
On Error GoTo 0
If http.Status <> 200 Then
CheckCompany = "refer: not screened (" & http.Status & " " & http.responseText & ")"
Exit Function
End If
json = http.responseText
resolution = JsonString(json, "resolution") ' the first field of the answer
result = JsonString(json, "status") ' the second
Select Case resolution
Case "matched"
If result = "clear" Then CheckCompany = "clear" Else CheckCompany = "refer: " & result
Case "ambiguous"
pos = InStr(1, json, """candidates"":", vbBinaryCompare)
Do
number = JsonString(json, "companyNumber", pos)
If pos = 0 Then Exit Do
choices = choices & vbCrLf & " " & number & " " & JsonString(json, "name", pos) & _
" (" & JsonString(json, "companyStatus", pos) & ")"
Loop
CheckCompany = "refer: which company? Choose one and screen it by its number." & choices
Case Else ' notFound
CheckCompany = "refer: Companies House has no company with a name like this"
End Select
Exit Function
Failed:
CheckCompany = "refer: not screened (" & Err.Description & ")"
End Function
' A JSON string, quoted. Anything outside plain ASCII is written as \uXXXX, so accented names arrive intact.
Private Function JsonText(text As String) As String
Dim i As Long, c As Long, out As String
For i = 1 To Len(text)
c = AscW(Mid$(text, i, 1)) And &HFFFF&
Select Case c
Case 34: out = out & "\"""
Case 92: out = out & "\\"
Case 32 To 126: out = out & ChrW$(c)
Case Else: out = out & "\u" & Right$("000" & Hex$(c), 4)
End Select
Next
JsonText = """" & out & """"
End Function
' The string value of the first "key" at or after position start, unescaped; "" where it is null or absent.
' start is moved past the value, or set to 0 when the key isn't found, so a loop can walk through the answer.
Private Function JsonString(json As String, key As String, Optional ByRef start As Long = 1) As String
Dim i As Long, ch As String, out As String
If start < 1 Then Exit Function
i = InStr(start, json, """" & key & """:", vbBinaryCompare)
If i = 0 Then
start = 0
Exit Function
End If
i = i + Len(key) + 3
If Mid$(json, i, 1) <> """" Then ' null, a number or true/false
start = i
Exit Function
End If
i = i + 1
Do While i <= Len(json)
ch = Mid$(json, i, 1)
If ch = """" Then Exit Do
If ch = "\" Then
i = i + 1
ch = Mid$(json, i, 1)
Select Case ch
Case "n": ch = vbLf
Case "r": ch = vbCr
Case "t": ch = vbTab
Case "b": ch = Chr$(8)
Case "f": ch = Chr$(12)
Case "u"
ch = ChrW$(CLng("&H" & Mid$(json, i + 1, 4)))
i = i + 4
End Select
End If
out = out & ch
i = i + 1
Loop
start = i + 1
JsonString = out
End Function
#!/usr/bin/env bash
# bash, with jq (https://jqlang.org). The key is read from SANCTIONS_API_KEY.
code=$(curl -sS -o answer.json -w '%{http_code}' https://screening.policy365.cloud/api/companies/check \
-H "X-Api-Key: $SANCTIONS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Highland Lamb Ltd", "reference": "Q-1", "disqualifications": true }')
if [ "$code" != "200" ]; then
echo "Not screened: HTTP $code"; cat answer.json; echo
exit 1
fi
jq -r '
if .resolution == "matched" then
"\(.company.name) (\(.company.companyNumber)): \(.status)"
+ (if .status == "clear" then "" else " - refer" end)
elif .resolution == "ambiguous" then
"Not screened. Choose one, then POST /api/companies/{number}/screen:",
(.candidates[] | " \(.companyNumber) \(.name) (\(.companyStatus)) score \(.score)")
else
"Not screened: Companies House has no company with a name like this. Refer."
end' answer.json
# pip install requests. The key is read from the SANCTIONS_API_KEY environment variable.
import os
import requests
BASE = "https://screening.policy365.cloud"
KEY = os.environ["SANCTIONS_API_KEY"]
response = requests.post(
f"{BASE}/api/companies/check",
headers={"X-Api-Key": KEY},
json={"name": "Highland Lamb Ltd", "reference": "Q-1", "disqualifications": True},
timeout=120,
)
if response.status_code != 200:
# 400, 502 or 503: not screened
raise SystemExit(f"Not screened: {response.status_code} {response.text}")
check = response.json()
if check["resolution"] == "matched":
company = check["company"]
print(f'{company["name"]} ({company["companyNumber"]}): {check["status"]}')
if check["status"] != "clear":
print("Refer: someone must look at this before going ahead.")
elif check["resolution"] == "ambiguous":
print("Not screened. Choose the company, then screen it with POST /api/companies/{number}/screen:")
for c in check["candidates"]:
same = ", same name" if c["sameName"] else ""
print(f' {c["companyNumber"]} {c["name"]} ({c["companyStatus"]}) score {c["score"]}{same}')
else: # notFound
print("Not screened: Companies House has no company with a name like this. Refer.")
# Windows PowerShell 5.1 or PowerShell 7. The key is read from the SANCTIONS_API_KEY environment variable.
if (-not $env:SANCTIONS_API_KEY) { throw "Set SANCTIONS_API_KEY to your key" }
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # for Windows PowerShell 5.1
$base = "https://screening.policy365.cloud"
$headers = @{ "X-Api-Key" = $env:SANCTIONS_API_KEY }
$body = @{ name = "Highland Lamb Ltd"; reference = "Q-1"; disqualifications = $true } | ConvertTo-Json
try {
$check = Invoke-RestMethod -Method Post -Uri "$base/api/companies/check" -Headers $headers `
-ContentType "application/json; charset=utf-8" -Body ([Text.Encoding]::UTF8.GetBytes($body))
}
catch {
# 400, 502 or 503: not screened
Write-Error "Not screened: $([int]$_.Exception.Response.StatusCode) $($_.ErrorDetails.Message)"
return
}
switch ($check.resolution) {
"matched" {
"$($check.company.name) ($($check.company.companyNumber)): $($check.status)"
if ($check.status -ne "clear") { "Refer: someone must look at this before going ahead." }
}
"ambiguous" {
"Not screened. Choose the company, then screen it with POST /api/companies/{number}/screen:"
foreach ($c in $check.candidates) {
" $($c.companyNumber) $($c.name) ($($c.companyStatus)) score $($c.score)"
}
}
default { "Not screened: Companies House has no company with a name like this. Refer." }
}
// check.mjs, run with: node check.mjs (Node 18 or later; fetch is built in).
// The key is read from the SANCTIONS_API_KEY environment variable.
const BASE = "https://screening.policy365.cloud";
const KEY = process.env.SANCTIONS_API_KEY;
if (!KEY) throw new Error("Set SANCTIONS_API_KEY to your key");
const response = await fetch(`${BASE}/api/companies/check`, {
method: "POST",
headers: { "X-Api-Key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({ name: "Highland Lamb Ltd", reference: "Q-1", disqualifications: true }),
});
if (!response.ok) {
// 400, 502 or 503: not screened
console.error(`Not screened: ${response.status} ${await response.text()}`);
process.exit(1);
}
const check = await response.json();
switch (check.resolution) {
case "matched":
console.log(`${check.company.name} (${check.company.companyNumber}): ${check.status}`);
if (check.status !== "clear") console.log("Refer: someone must look at this before going ahead.");
break;
case "ambiguous":
console.log("Not screened. Choose the company, then screen it with POST /api/companies/{number}/screen:");
for (const c of check.candidates) {
console.log(` ${c.companyNumber} ${c.name} (${c.companyStatus}) score ${c.score}${c.sameName ? ", same name" : ""}`);
}
break;
default: // notFound
console.log("Not screened: Companies House has no company with a name like this. Refer.");
}
Screen one name, as CSV
GET /api/screen/csv for one name, reading status as the first field.
// Program.cs in a console app (dotnet new console), .NET 8 or later.
// The key is read from the SANCTIONS_API_KEY environment variable, not written in the code.
var key = Environment.GetEnvironmentVariable("SANCTIONS_API_KEY")
?? throw new InvalidOperationException("Set SANCTIONS_API_KEY to your key");
using var http = new HttpClient { BaseAddress = new Uri("https://screening.policy365.cloud/") };
http.DefaultRequestHeaders.Add("X-Api-Key", key);
var url = "api/screen/csv?type=person"
+ "&name=" + Uri.EscapeDataString("Oleg Deripaska")
+ "&dob=1968-01"
+ "&reference=" + Uri.EscapeDataString("ACCESS-1");
using var response = await http.GetAsync(url);
var text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
// anything but 200 is not screened
Console.WriteLine($"Not screened: {(int)response.StatusCode} {text}");
return;
}
var row = text.Split("\r\n")[1]; // line 0 is the header
var status = row[..row.IndexOf(',')]; // status is the first field, never quoted
Console.WriteLine(status); // clear, review or match
' A standard module of its own, in Access or Excel. Nothing to add under Tools > References.
Option Explicit
Private Const SCREEN_URL As String = "https://screening.policy365.cloud/api/screen/csv"
Private Const API_KEY As String = "ssk_your_key_here" ' better kept in a table or a settings sheet than in code
' "clear", "review" or "match"; "not screened: ..." for anything else, which must be treated as not screened.
' For example: Debug.Print ScreenName("person", "Oleg Deripaska", "1968-01-02", "ACCESS-1")
Public Function ScreenName(kind As String, fullName As String, Optional dob As String = "", Optional reference As String = "") As String
Dim http As Object, row As String
On Error GoTo Failed
Set http = CreateObject("MSXML2.ServerXMLHTTP.6.0")
http.Open "GET", SCREEN_URL & "?type=" & UrlEncode(kind) & "&name=" & UrlEncode(fullName) & _
"&dob=" & UrlEncode(dob) & "&reference=" & UrlEncode(reference), False
http.setRequestHeader "X-Api-Key", API_KEY
http.send
If http.Status <> 200 Then
ScreenName = "not screened: " & http.Status & " " & http.responseText
Exit Function
End If
row = Split(http.responseText, vbCrLf)(1) ' line 0 is the header
ScreenName = Left$(row, InStr(row, ",") - 1) ' status is the first field, never quoted
Exit Function
Failed:
ScreenName = "not screened: " & Err.Description
End Function
' Percent-encodes as UTF-8, so accented names (José, Łukasz) arrive as they were typed.
Public Function UrlEncode(text As String) As String
Dim i As Long, c As Long, out As String
For i = 1 To Len(text)
c = AscW(Mid$(text, i, 1)) And &HFFFF&
Select Case c
Case 48 To 57, 65 To 90, 97 To 122, 45, 46, 95, 126
out = out & ChrW$(c)
Case Is < &H80
out = out & Pct(c)
Case Is < &H800
out = out & Pct(&HC0 Or (c \ &H40)) & Pct(&H80 Or (c And &H3F))
Case Else
out = out & Pct(&HE0 Or (c \ &H1000)) & Pct(&H80 Or ((c \ &H40) And &H3F)) & Pct(&H80 Or (c And &H3F))
End Select
Next
UrlEncode = out
End Function
Private Function Pct(b As Long) As String
Pct = "%" & Right$("0" & Hex$(b), 2)
End Function
# bash or zsh (on Windows: Git Bash or WSL). The key is read from SANCTIONS_API_KEY.
# -G sends the --data-urlencode values in the query string, encoded.
curl -sS -G https://screening.policy365.cloud/api/screen/csv \
-H "X-Api-Key: $SANCTIONS_API_KEY" \
--data-urlencode "type=person" \
--data-urlencode "name=Oleg Deripaska" \
--data-urlencode "dob=1968-01" \
--data-urlencode "reference=ACCESS-1" \
-w 'HTTP %{http_code}\n'
# pip install requests. The key is read from the SANCTIONS_API_KEY environment variable.
import csv
import io
import os
import requests
BASE = "https://screening.policy365.cloud"
KEY = os.environ["SANCTIONS_API_KEY"]
response = requests.get(
f"{BASE}/api/screen/csv",
headers={"X-Api-Key": KEY},
params={"type": "person", "name": "Oleg Deripaska", "dob": "1968-01", "reference": "ACCESS-1"},
timeout=60,
)
if response.status_code != 200:
# anything but 200 is not screened
raise SystemExit(f"Not screened: {response.status_code} {response.text}")
row = next(csv.DictReader(io.StringIO(response.text)))
print(row["status"], row["matchedName"], row["screeningId"])
# Windows PowerShell 5.1 or PowerShell 7. The key is read from the SANCTIONS_API_KEY environment variable.
if (-not $env:SANCTIONS_API_KEY) { throw "Set SANCTIONS_API_KEY to your key" }
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # for Windows PowerShell 5.1
$base = "https://screening.policy365.cloud"
$headers = @{ "X-Api-Key" = $env:SANCTIONS_API_KEY }
$query = "type=person" +
"&name=" + [uri]::EscapeDataString("Oleg Deripaska") +
"&dob=1968-01" +
"&reference=" + [uri]::EscapeDataString("ACCESS-1")
try {
$text = Invoke-RestMethod -Uri "$base/api/screen/csv?$query" -Headers $headers
}
catch {
# anything but 200 is not screened
Write-Error "Not screened: $([int]$_.Exception.Response.StatusCode) $($_.ErrorDetails.Message)"
return
}
$row = $text -split "`r?`n" | Where-Object { $_ } | ConvertFrom-Csv | Select-Object -First 1
"$($row.status) $($row.matchedName) $($row.screeningId)"
// screen-csv.mjs, run with: node screen-csv.mjs (Node 18 or later; fetch is built in).
// The key is read from the SANCTIONS_API_KEY environment variable.
const BASE = "https://screening.policy365.cloud";
const KEY = process.env.SANCTIONS_API_KEY;
if (!KEY) throw new Error("Set SANCTIONS_API_KEY to your key");
const query = new URLSearchParams({ type: "person", name: "Oleg Deripaska", dob: "1968-01", reference: "ACCESS-1" });
const response = await fetch(`${BASE}/api/screen/csv?${query}`, { headers: { "X-Api-Key": KEY } });
const text = await response.text();
if (!response.ok) {
// anything but 200 is not screened
console.error(`Not screened: ${response.status} ${text}`);
process.exit(1);
}
const row = text.split("\r\n")[1]; // line 0 is the header
const status = row.slice(0, row.indexOf(",")); // status is the first field, never quoted
console.log(status); // clear, review or match
OpenAPI
The OpenAPI document is at https://screening.policy365.cloud/openapi/v1.json. Import it into
Postman or Insomnia, or generate a client from it (NSwag, Kiota, openapi-generator). Send your key in the
X-Api-Key header.