Bans, Exemptions & Police Checks
Screening a worker produces three compliance records that sit alongside their credentials. The Screening an Applicant tutorial and the Workers & Credentials tutorial touch these in passing — a screening package can require exemptions and ban checks, and a completed screen can carry a police check — this guide covers them in depth:
- Bans — a read-only record that a ban check ran for a worker against a register (e.g. the Aged Care Banning Order register), plus any matches found.
- Exemptions — a dispensation that lets a worker engage without holding a normally-required credential (e.g. a Queensland Blue Card Exemption). Full read/write lifecycle.
- Police checks — the provider integration surface: the inbound provider webhook receiver, the background poller, and a connection-test helper. This is plumbing the Oho app and your providers drive, not a CRUD entity you create over REST.
Each maps to its own spec: api/v1/bans.openapi.yaml, api/v1/exemptions.openapi.yaml, and
api/v1/policechecks.openapi.yaml.
Read API Basics for base URLs, the Authorization header, the response envelope,
and error shapes, and Authentication & Tokens for how scopes work per entity
type. The examples assume $OHO_BASE and $OHO_TOKEN are set.
Entities and authorization
Each surface authorizes against its own entity type and carries its own identifier shape:
| Surface | Entity type | ID / addressing | Operations |
|---|---|---|---|
| Bans | ban | Full URN, e.g. urn:li:ban:(AGED_CARE_QUALITY,89e1…) | READ only |
| Exemptions | exemption | exm_<id>, e.g. exm_V1StGXR8Z5jdHi6B | READ, CREATE, UPDATE, DELETE |
| Police checks | (see below) | Provider externalId, not an Oho ID | (see below) |
A token scoped only to ban cannot read exemptions, and vice versa — scopes do not cascade. See
Scopes: per-entity-type authorization.
The police-check endpoints are a mix: the inbound webhook is HMAC-authenticated (no bearer token),
while the poller and connection test run in your authenticated session.
Bans
A ban is a per-(worker, register) record: it captures that a check ran, the rolled-up status of
the most recent run, and links to any matched register entries. The API is read-only — bans are
produced by Oho's checking pipeline, not created over REST. Workers that need attention surface as
review tasks in the app; this API is for querying and reconciliation.
List ban records
GET /bans returns ban records newest-first when sorted. Filter by worker, register, status, or
recency.
# Records needing review on the aged-care register, most recently checked first
curl -sS "$OHO_BASE/bans?registry=AGED_CARE_QUALITY&status=REVIEW_REQUIRED&sort=lastCheckedAt:desc" \
-H "Authorization: Bearer $OHO_TOKEN"
# Every ban record for one worker (URN, not the wkr_ ID)
curl -sS "$OHO_BASE/bans?workerUrn=urn:li:worker:wkr_V1StGXR8Z5jdHi6B" \
-H "Authorization: Bearer $OHO_TOKEN"
| Parameter | Notes |
|---|---|
query | Full-text search; defaults to * |
workerUrn | Full worker/applicant URN to filter by |
registry | Register code; upper-cased server-side |
status | One of the ban statuses below |
checkedAfter / checkedBefore | ISO-8601 instant or bare date; After is inclusive, Before exclusive |
sort | field:asc|desc — only lastCheckedAt is sortable |
page / pageSize | Paging; pageSize defaults to 25, max 100 |
includeDeleted | Include soft-deleted records (default false) |
An unparseable checkedAfter / checkedBefore returns 400.
Fetch one ban record
GET /bans/{banUrn} takes the URL-encoded full ban URN. The entity-type segment must be ban.
curl -sS "$OHO_BASE/bans/urn%3Ali%3Aban%3A%28AGED_CARE_QUALITY%2C89e14427e785ca84%29" \
-H "Authorization: Bearer $OHO_TOKEN"
{
"data": {
"urn": "urn:li:ban:(AGED_CARE_QUALITY,89e14427e785ca84)",
"id": "(AGED_CARE_QUALITY,89e14427e785ca84)",
"type": "ban",
"attributes": {
"displayName": "Aged Care Banning Order — Jordan Avery",
"workerUrn": "urn:li:worker:wkr_V1StGXR8Z5jdHi6B",
"registry": "AGED_CARE_QUALITY",
"status": "REVIEW_REQUIRED",
"lastCheckedAt": "2026-06-28T11:02:55Z",
"highestMatchScore": 87,
"matchedVerifiedBanUrns": [
"urn:li:verifiedBan:(AGED_CARE_QUALITY,abc123)"
],
"weakSignalMatchedUrns": [],
"notes": null
}
},
"meta": { "requestId": "f3c1a0e2-..." }
}
matchedVerifiedBanUrns are matches strong enough to drive the headline status;
weakSignalMatchedUrns are lower-confidence matches that surface as low-priority triage rather
than flagging the worker. A URN whose type segment isn't ban returns 400; an unknown URN
returns 404.
Ban statuses
| Status | Meaning |
|---|---|
CLEAR | Check ran; no match against the register |
REVIEW_REQUIRED | A match needs a human decision |
MATCH_FOUND | A confirmed match against the register |
CHECK_FAILED | The check could not complete (e.g. register unavailable) |
PENDING | A check has been initiated but no result is recorded yet |
For what each register verifies against, see the Ban Checks concept pages.
Exemptions
An exemption answers "is this worker excused from holding X?" rather than "does this worker hold
X?". It has its own verification lifecycle and full CRUD. Every exemption carries an
acknowledgement trail — a reason you supply, plus the actor and timestamp Oho stamps on
create — that is preserved for audit.
Create an exemption
POST /exemptions requires identification.exemptionType, acknowledgement.reason, and at least
one owner — owners.worker (wkr_…) or owners.applicant (app_…). The server stamps
acknowledgement.acknowledgedBy (your actor URN) and acknowledgedAt; both are immutable
afterwards.
curl -sS -X POST "$OHO_BASE/exemptions" \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"identification": {
"displayName": "Blue Card Exemption — Jordan Avery",
"exemptionType": "BLUE_CARD_EXEMPTION",
"exemptsFromCredentialType": "BLUE_CARD_QLD",
"referenceNumber": "BCE-2026-0042"
},
"issuing": { "grantingAuthority": "Blue Card Services", "jurisdiction": "QLD", "issueDate": "2026-01-15", "expiryDate": "2029-01-15" },
"verification": { "status": "ACTIVE", "isActive": true, "eligibility": "MAY_ENGAGE" },
"acknowledgement": { "reason": "Registered teacher exempt under the Blue Card scheme." },
"owners": { "worker": "wkr_V1StGXR8Z5jdHi6B" }
}'
The server returns 201 with the generated exm_ ID and a Location header:
{
"data": {
"id": "exm_V1StGXR8Z5jdHi6B",
"type": "exemption",
"attributes": {
"identification": {
"displayName": "Blue Card Exemption — Jordan Avery",
"exemptionType": "BLUE_CARD_EXEMPTION",
"exemptsFromCredentialType": "BLUE_CARD_QLD",
"referenceNumber": "BCE-2026-0042"
},
"issuing": {
"grantingAuthority": "Blue Card Services",
"jurisdiction": "QLD",
"issueDate": "2026-01-15",
"expiryDate": "2029-01-15"
},
"verification": {
"status": "ACTIVE",
"isActive": true,
"eligibility": "MAY_ENGAGE"
},
"acknowledgement": {
"reason": "Registered teacher exempt under the Blue Card scheme.",
"acknowledgedBy": "urn:li:corpuser:you@example.com",
"acknowledgedAt": "2026-06-29T02:14:07Z"
},
"owners": { "worker": "wkr_V1StGXR8Z5jdHi6B" }
},
"lastUpdated": "2026-06-29T02:14:07Z"
},
"meta": { "requestId": "..." }
}
export EXM="exm_V1StGXR8Z5jdHi6B"
- Missing
identification.exemptionTypeoracknowledgement.reason→400 ownerswith neitherworkernorapplicant→400owners.worker/owners.applicantnot matching thewkr_<id>/app_<id>pattern →400
Fetch, list, and filter
GET /exemptions/{exemptionId} returns the single-record envelope. GET /exemptions lists them
with filters:
# Active QLD exemptions for one worker
curl -sS "$OHO_BASE/exemptions?ownerType=worker&ownerId=wkr_V1StGXR8Z5jdHi6B&jurisdiction=QLD&status=ACTIVE" \
-H "Authorization: Bearer $OHO_TOKEN"
# By exemption type, most recently updated first
curl -sS "$OHO_BASE/exemptions?exemptionType=BLUE_CARD_EXEMPTION&sort=lastUpdated:desc" \
-H "Authorization: Bearer $OHO_TOKEN"
| Parameter | Notes |
|---|---|
ownerType + ownerId | Apply together to filter by owner; ownerType is worker or applicant |
exemptionType | e.g. BLUE_CARD_EXEMPTION |
jurisdiction | e.g. QLD |
status | ACTIVE, EXPIRED, PENDING, REVOKED, SUSPENDED |
eligibility | MAY_ENGAGE, MAY_NOT_ENGAGE, IN_PROGRESS, REVIEW_REQUIRED, ERROR |
updatedAfter / updatedBefore | ISO-8601 instant or bare date; After inclusive, Before exclusive |
sort | field:asc|desc — only lastUpdated is sortable |
page / pageSize | Paging; pageSize defaults to 25, max 100 |
includeDeleted | Include soft-deleted records (default false) |
The owner filter only takes effect when both ownerType and ownerId are supplied — ownerType
on its own is ignored. An invalid ownerType returns 400 only when paired with an ownerId.
Update: PATCH vs PUT
PATCH changes only the fields you send (application/merge-patch+json or application/json).
acknowledgement.reason is editable; owners, acknowledgement.acknowledgedBy, and
acknowledgement.acknowledgedAt are immutable — supplying any of them returns 400.
curl -sS -X PATCH "$OHO_BASE/exemptions/$EXM" \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{ "acknowledgement": { "reason": "Updated: confirmed exemption letter sighted." } }'
PUT replaces the record but requires only identification.exemptionType. The owners and the
acknowledgement trail (reason / acknowledgedBy / acknowledgedAt), along with displayName,
referenceNumber, and status, are carried forward from the existing record — you cannot
clear them, and supplying immutable fields returns 400.
Soft delete
curl -sS -X DELETE "$OHO_BASE/exemptions/$EXM" -H "Authorization: Bearer $OHO_TOKEN" -i # 204
Sets deleted: true and preserves the record; re-query with includeDeleted=true to confirm it
still exists. For what each exemption type means, see the
Exemptions concept pages.
Police checks
The police-check spec is not a CRUD surface for the policeCheck entity — that entity is
driven by the screening flow and the Oho app. This spec documents the three integration endpoints
that connect Oho to a police-check provider (NCC — National Crime Check, and PID — PharmacyID):
- The inbound provider webhook receiver.
- The background poller that catches updates a webhook missed.
- A PID connection test for the setup wizard.
Acquiring the provider credential (the browser-redirect OAuth consent against NCC) is covered in Authentication → NCC police-check consent flow. This page picks up after that, once Oho holds the provider credential and the signing secret.
Inbound webhook receiver (HMAC-authenticated)
POST /policechecks/webhook/{provider} is how a provider tells Oho a check progressed or
completed. It is the highest-risk, externally-called endpoint in the platform, so it is not
authenticated by a bearer token — it is verified solely by an HMAC signature over the raw request
body.
The body is read as raw bytes (so the HMAC sees the exact payload) and then parsed leniently as
JSON. The external check identifier is taken from the first non-empty of externalId,
applicationId, checkId, id, and is matched to an existing policeCheck.
HMAC verification contract (NCC)
The provider sends three headers:
| Header | Required | Purpose |
|---|---|---|
X-NCC-Signature | Yes | Lowercase hex of the HMAC-SHA256 digest |
X-NCC-Timestamp | Yes | Provider timestamp; included in the signed message |
X-NCC-Event-Id | No | Event id used for idempotency / replay suppression |
Verification proceeds as:
- The signed message bytes are
UTF8(X-NCC-Timestamp) + "." + rawBody— the timestamp string, a literal., then the raw body bytes. - The expected digest is
HMAC_SHA256(secret, signedMessage), hex-encoded lowercase, compared toX-NCC-Signaturein constant time. - The
secretis the per-tenant signing secret resolved from the encrypted secret store (configured at Settings → Police Checks). If it is unset, the request is rejected with503. - Header lookups are case-insensitive.
- Replay protection uses
X-NCC-Event-Id. The timestamp is signed over but is not currently validated against a clock-skew window.
A worked signing example (illustrative):
TS="1719626400"
BODY='{"externalId":"NCC-ABC-123","status":"complete","result":"NDCO"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$NCC_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -sS -X POST "$OHO_BASE/policechecks/webhook/NCC" \
-H "Content-Type: application/json" \
-H "X-NCC-Signature: $SIG" \
-H "X-NCC-Timestamp: $TS" \
-H "X-NCC-Event-Id: evt_98f2" \
--data-raw "$BODY"
A success returns 200 with whether the event was newly applied or recognised as a duplicate:
{ "status": "applied", "policeCheckUrn": "urn:li:policeCheck:..." }
The payload keys are read leniently — status is also accepted as checkStatus, result as
outcome, resultDate as completedAt, and result_url as resultUrl. These map onto the
police-check status (the provider's raw status and result code, e.g. NDCO / DCO, the
result-page URL, and whether manual review is required).
| Status | Meaning |
|---|---|
200 | Applied, or recognised as an idempotent duplicate |
400 | Unknown provider, missing signature/timestamp header, unparseable payload, or no externalId |
401 | Signature present but invalid (mismatch) |
404 | No policeCheck matches the externalId |
500 | Aspect-write failure |
501 | Provider is registered but does not support webhooks |
503 | No per-tenant webhook signing secret configured |
This endpoint appears in the API Reference as well.
Because there is no bearer token, the signature is the only thing standing between a real provider callback and a forged one. Always verify the HMAC over the raw bytes, keep the signing secret out of source control, and rotate it if it leaks.
Background poller
POST /policechecks/poll/run polls open provider checks for updates — the safety net for results
a webhook never delivered. It runs in your authenticated session (bearer token) and is hidden from
the live Swagger UI. The legacy alias POST /policechecks/poll/ncc maps to the same handler.
curl -sS -X POST "$OHO_BASE/policechecks/poll/run" \
-H "Authorization: Bearer $OHO_TOKEN"
{
"openCount": 12,
"returnedCount": 12,
"updatedCount": 3,
"errorCount": 0,
"message": "ok"
}
The optional organizationUrn query parameter scopes the poll; left blank, it defaults to the
global-settings sentinel urn:li:globalSettings:0. An unauthenticated call returns 401.
PID connection test
POST /policechecks/pid/test-connection runs a server-side connectivity check against the PID/PRM
token endpoint using deployment-level credentials. It powers the setup wizard, so logical
failures come back as 200 with ok: false (the wizard renders the message); the only non-200
path is 401 when your Oho session is unauthenticated.
curl -sS -X POST "$OHO_BASE/policechecks/pid/test-connection" \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "environment": "TEST" }'
{ "ok": true, "environment": "TEST", "expiresIn": 3600 }
environment of PRODUCTION selects production credentials and URL; any other value (including
TEST) uses the TEST environment. On failure, ok is false and an error message is included.
Where to go next
- Authentication & Tokens — token scopes, and the NCC OAuth consent handshake
- Ban Checks · Exemptions · Police Check — what each register and check verifies
- Screening an Applicant — where exemptions and ban checks enter a screening package, and where police checks fit the hire flow
- API Reference — every endpoint, field, and query parameter
- Webhooks & Event Delivery — Oho's outbound event deliveries (distinct from the inbound provider webhook above)