Skip to main content

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.

Before you start

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:

SurfaceEntity typeID / addressingOperations
BansbanFull URN, e.g. urn:li:ban:(AGED_CARE_QUALITY,89e1…)READ only
Exemptionsexemptionexm_<id>, e.g. exm_V1StGXR8Z5jdHi6BREAD, 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"
ParameterNotes
queryFull-text search; defaults to *
workerUrnFull worker/applicant URN to filter by
registryRegister code; upper-cased server-side
statusOne of the ban statuses below
checkedAfter / checkedBeforeISO-8601 instant or bare date; After is inclusive, Before exclusive
sortfield:asc|desc — only lastCheckedAt is sortable
page / pageSizePaging; pageSize defaults to 25, max 100
includeDeletedInclude 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

StatusMeaning
CLEARCheck ran; no match against the register
REVIEW_REQUIREDA match needs a human decision
MATCH_FOUNDA confirmed match against the register
CHECK_FAILEDThe check could not complete (e.g. register unavailable)
PENDINGA 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"
Common validation errors
  • Missing identification.exemptionType or acknowledgement.reason400
  • owners with neither worker nor applicant400
  • owners.worker / owners.applicant not matching the wkr_<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"
ParameterNotes
ownerType + ownerIdApply together to filter by owner; ownerType is worker or applicant
exemptionTypee.g. BLUE_CARD_EXEMPTION
jurisdictione.g. QLD
statusACTIVE, EXPIRED, PENDING, REVOKED, SUSPENDED
eligibilityMAY_ENGAGE, MAY_NOT_ENGAGE, IN_PROGRESS, REVIEW_REQUIRED, ERROR
updatedAfter / updatedBeforeISO-8601 instant or bare date; After inclusive, Before exclusive
sortfield:asc|desc — only lastUpdated is sortable
page / pageSizePaging; pageSize defaults to 25, max 100
includeDeletedInclude 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):

  1. The inbound provider webhook receiver.
  2. The background poller that catches updates a webhook missed.
  3. A PID connection test for the setup wizard.
The NCC consent handshake lives elsewhere

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:

HeaderRequiredPurpose
X-NCC-SignatureYesLowercase hex of the HMAC-SHA256 digest
X-NCC-TimestampYesProvider timestamp; included in the signed message
X-NCC-Event-IdNoEvent id used for idempotency / replay suppression

Verification proceeds as:

  1. The signed message bytes are UTF8(X-NCC-Timestamp) + "." + rawBody — the timestamp string, a literal ., then the raw body bytes.
  2. The expected digest is HMAC_SHA256(secret, signedMessage), hex-encoded lowercase, compared to X-NCC-Signature in constant time.
  3. The secret is the per-tenant signing secret resolved from the encrypted secret store (configured at Settings → Police Checks). If it is unset, the request is rejected with 503.
  4. Header lookups are case-insensitive.
  5. 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).

StatusMeaning
200Applied, or recognised as an idempotent duplicate
400Unknown provider, missing signature/timestamp header, unparseable payload, or no externalId
401Signature present but invalid (mismatch)
404No policeCheck matches the externalId
500Aspect-write failure
501Provider is registered but does not support webhooks
503No per-tenant webhook signing secret configured

This endpoint appears in the API Reference as well.

Verify before you trust

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