Datasource Background Check Partner API: Integration Guide
Order and track background checks for the churches you serve, from your own platform. Every chapter below stands on
its own; links point at the exact chapter that has more. The interactive reference (every route, field and enum) is at
/scalar, the machine-readable OpenAPI 3.1 document at /openapi/v1.json, the error catalog at /docs/errors.md, and
everything in one file for AI coding agents at /llms-full.txt.
1. Start here
What the API does. You connect a church once, either on a page we host (it enters its own details and billing) or with one API call. Then you order background checks for its volunteers and staff by name and email. We email each applicant to collect their details and consent, and send you signed webhooks as each check moves along. You never handle SSNs, dates of birth, card numbers or passwords.
| Environment | Base URL | API key prefix | Checks |
|---|---|---|---|
| Test (sandbox) | https://dev.partners.cbgc.io |
pk_test_ |
Not real; nothing is charged. Checks can be moved to any status (chapter 11). |
| Production | https://partners.cbgc.io |
pk_live_ |
Real and billed to the church. |
Getting keys. Sandbox keys are self-serve: sign up at https://join.cbgc.io/sandbox/, confirm your email, and you
get a test API key (pk_test_...) and a webhook signing secret (whsec_...), each shown once. A person from our team
follows up to help. Production keys come from Datasource, through an expiring single-view link, once your integration
works end to end in the sandbox. Store keys and secrets in a secret manager, never in browser or mobile app code.
First check in ten minutes (test environment; replace pk_test_YOUR_KEY):
Check your key:
curl https://dev.partners.cbgc.io/status -H "X-Api-Key: pk_test_YOUR_KEY"Register your webhook URL and connect return URL (chapter 9):
curl -X PUT https://dev.partners.cbgc.io/settings -H "X-Api-Key: pk_test_YOUR_KEY" -H "Content-Type: application/json" \ -d '{"webhookUrl":"https://app.example.com/cbc/webhooks","connectReturnUrls":["https://app.example.com/cbc/connect-done"]}'Connect a church (chapter 4):
POST /connect/sessions, then open the returnedconnectUrlin a browser and fill in the page (choose invoice to finish without a card).See what you can order:
GET /products(chapter 5).Order a check (chapter 6):
curl -X POST https://dev.partners.cbgc.io/clients/org-1042/checks -H "X-Api-Key: pk_test_YOUR_KEY" -H "Content-Type: application/json" \ -d '{"externalIdentifier":"vol-8841","firstName":"Jordan","lastName":"Kim","applicantEmail":"jordan.kim@example.com","level":2}'
Then move it to a result (chapter 11) and watch your webhook receive check.updated.
2. Concepts and glossary
You (partner, one API key)
└── Client = one church {clientRef}: clientId or your externalClientId
└── Check = one applicant's background check externalIdentifier (yours), id
| Term | What it is | Format | Example | Who creates it |
|---|---|---|---|---|
| Partner | You. Your API key identifies you; you only ever see your own data. | n/a | n/a | Datasource |
| Client | One church you serve, with its own billing and portal logins at Datasource. | object | Grace Community Church | You (connect page or POST /clients) |
clientId |
Our id for a client. | string, cl_ + 24 characters |
cl_5b0e6f2a8c1d4e7b9a3f2d6c |
Datasource |
externalClientId |
Your id for a client. Unique among your clients. | string, ≤ 100 characters | org-1042 |
You |
{clientRef} |
Any path segment that names a client: clientId or externalClientId. |
string | org-1042 |
You |
| Check | One background check for one applicant. | object | check chk_9c2e4f6a1b3d4e5f8a7b6c5d |
You (an order) |
id |
Our id for a check, set when it is ordered. The reliable key for a check. | string, chk_ + 24 characters |
chk_9c2e4f6a1b3d4e5f8a7b6c5d |
Datasource |
externalIdentifier |
Your id for a check. Unique across all your clients; reuse it on retries. | string, ≤ 100 characters | vol-8841 |
You |
{checkRef} (path) |
Any path segment that names a check: id or externalIdentifier. |
string | vol-8841 |
You |
level |
A product, 1 to 6. We translate it to each church's own product. | integer 1 to 6 | 2 |
Datasource defines; you choose |
status |
Where a check is; contract-stable. | enum (7 values) | CREATED |
Datasource |
stage |
A finer view of status. |
enum (7 values) | awaiting_applicant |
Datasource |
| Batch | Up to 500 checks ordered in one call. | object | batchId 3f1c0b9e-... |
You |
batchReference |
Your idempotency key for a batch. | string, ≤ 100 characters | onboarding-2026-10-16 |
You |
| Connect session | One run of the hosted connect page for one church. | object | sessionId 8d2f6c14-... |
You |
| Event | One webhook delivery's content. | object | check.updated |
Datasource |
eventId |
Unique id of an event, the same on every retry of it. Your dedupe key. | UUID string | e7a1c3b5-2d4f-4a6b-8c9d-0e1f2a3b4c5d |
Datasource |
Conventions. JSON in and out, camelCase names. All timestamps are UTC, ISO 8601 (for example
2026-10-16T15:10:00Z). Prices are in USD. URL-encode ids you put in a path. Ignore fields and webhook events you don't
know: we add them without notice (chapter 15).
3. Authentication
Send your API key on every request, in either header:
X-Api-Key: pk_test_YOUR_KEY
Authorization: Bearer pk_test_YOUR_KEY
- A test key works only on
https://dev.partners.cbgc.ioand a live key only onhttps://partners.cbgc.io. - Rotation without downtime. You can hold more than one active key. Ask Datasource for a new key, deploy it, then ask us to
revoke the old one.
GET /statusanswers200for any working key. 401 invalid_api_key: the key is missing, invalid, revoked, or for the other environment. Don't retry until it's fixed.429 rate_limited: more than 20 requests a minute with an invalid key came from your IP address. Wait theRetry-Afterseconds (60). Requests with a valid key are never throttled this way.- Keep keys on your server. Never put them in a browser, mobile app, or a URL.
4. Connecting a church
A church enters its own details and billing (card or invoice) on a page we host, so you never see card data or passwords. One run of the page is a connect session.
Before the first session, register your webhook URL and your return URLs with PUT /settings (chapter 9).
The flow:
- When the church's admin clicks your "Connect to Datasource" button, your server calls
POST /connect/sessions. - You redirect the admin's browser to the returned
connectUrl(a normal top-level navigation; no iframe). The link works once and must be opened within 15 minutes. - The church fills in four steps: church details, primary contact, billing (card or invoice), and the service agreement. If it chooses Already a Datasource customer?, it sends a migration request instead (chapter 13).
- We create the church's account and email its primary contact a sign-in with a temporary password (changed at first sign-in). The email comes from Datasource; you never receive passwords.
- We send the browser to
{returnUrl}?session_id={sessionId}&state={state}and send youclient.connected. - Confirm with the
client.connectedwebhook orGET /connect/sessions/{sessionId}before treating the church as connected. The redirect carries no secrets and can be forged.
Create a session. POST /connect/sessions:
| Field | Type | Required | Limits / format | Example | Meaning |
|---|---|---|---|---|---|
externalClientId |
string | yes | ≤ 100 characters | org-1042 |
Your id for the church; becomes the client's externalClientId. |
churchName |
string | yes | ≤ 200 characters | Grace Community Church |
Shown read-only on the page. |
returnUrl |
string | yes | URI; must exactly match a registered connectReturnUrls entry |
https://app.example.com/cbc/connect-done |
Where the browser goes when done. We append session_id and state. |
prefill.contactName |
string | no | ≤ 200 characters | Dana Lopez |
Pre-fills the contact step; the church can change it. |
prefill.contactEmail |
string | no | ≤ 200 characters | dana@gracecommunity.example |
Pre-fills the contact's email, which becomes their sign-in. |
prefill.contactPhone |
string | no | ≤ 200 characters | (303) 555-0142 |
Pre-fills the contact's phone. |
curl -X POST https://dev.partners.cbgc.io/connect/sessions \
-H "X-Api-Key: pk_test_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"externalClientId":"org-1042","churchName":"Grace Community Church",
"returnUrl":"https://app.example.com/cbc/connect-done",
"prefill":{"contactName":"Dana Lopez","contactEmail":"dana@gracecommunity.example","contactPhone":"(303) 555-0142"}}'
// Node 20+, no libraries
const BASE = "https://dev.partners.cbgc.io";
const API_KEY = process.env.DATASOURCE_API_KEY; // pk_test_YOUR_KEY
const res = await fetch(`${BASE}/connect/sessions`, {
method: "POST",
headers: { "X-Api-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
externalClientId: "org-1042",
churchName: "Grace Community Church",
returnUrl: "https://app.example.com/cbc/connect-done",
prefill: { contactName: "Dana Lopez", contactEmail: "dana@gracecommunity.example" },
}),
});
if (res.status !== 201) throw new Error(`connect session failed: ${res.status} ${(await res.json()).code}`);
const session = await res.json();
// Store session.sessionId with your church record BEFORE redirecting: the webhook can arrive before the browser does.
// Then redirect the admin's browser to session.connectUrl.
201 Created:
{
"sessionId": "8d2f6c14-3b7a-4e0e-9c55-1a9b7d3e2f60",
"connectUrl": "https://dev.partners.cbgc.io/connect?t=Zk3qL0b2m9Xr7Tn4Vw1Yc6Hs8Pd5Ja2Ue0Gi3Ko7Mf9",
"expiresAt": "2026-10-16T15:19:05Z"
}
Errors: 400 validation_failed (a field missing or too long), 400 invalid_return_url (not registered), 409 client_already_connected (that externalClientId is already an active client), 429 rate_limited (more than 30
sessions a minute; Retry-After: 60). Creating a session isn't idempotent: an unused session simply expires.
The return. state in the redirect is completed, cancelled or migration_requested. If the session expires or
is locked, the browser sees a page on our side and is not sent back; poll GET /connect/sessions/{sessionId} and
offer to start again.
Confirm. GET /connect/sessions/{sessionId}:
{
"sessionId": "8d2f6c14-3b7a-4e0e-9c55-1a9b7d3e2f60",
"externalClientId": "org-1042",
"state": "completed",
"clientId": "cl_5b0e6f2a8c1d4e7b9a3f2d6c"
}
state |
Meaning |
|---|---|
created |
Waiting for the church to open the link. |
opened |
The church is on the page (including while its account is being created). |
completed |
The client exists; clientId is set. |
cancelled |
The church cancelled. |
expired |
The link wasn't opened within 15 minutes, an hour passed after opening, or the session was locked after too many failed attempts (10 failed submits, or 3 failed billing attempts). Create a new session. |
migration_requested |
The church says it's already a Datasource customer; we verify it by hand (chapter 13). |
What the church sees. A used link shows "This link was already used"; an expired one "This link has expired". Both ask the church to start again from your app. Submitting the page twice (a double click, a second tab) is safe: the second submit is told its account is being created, and only one account is ever made. A failed card is shown on the billing step to try again; no account exists until billing works. For invoice, the church's account takes the billing setup copied from your account, plus the church's billing contact.
Creating a client yourself. If the church's details are already in your platform, POST /clients creates the
client directly. Required: legalCompanyName, primaryContactFirstName, primaryContactLastName,
primaryContactEmail, primaryContactPhone, streetLine1, city, state, postalCode. Optional: externalClientId
(≤ 100 characters, unique among your clients; 409 external_client_id_in_use if taken), dbaCompanyName,
companyPhone, streetLine2, country (default US), and the billing... address fields. The church's primary contact
gets a portal login and an email from Datasource with their username and a temporary password (changed at first sign-in), the same
as on the connect page: no password is ever returned to you. The response includes primaryUsername (their email, or a
derived name such as dana-3f9a1c if that email is already a login) and welcomeEmailSent. Look a client up with GET /clients?externalClientId=org-1042 or
GET /clients/{clientRef}; portalUrl is where the church's staff sign in (open it in a new tab).
5. Products and levels
You order a level from 1 to 6; we translate it to the church's own product. GET /products lists every level with
its base price from your account; GET /clients/{clientRef}/products lists the levels one church can order, priced
for it (levels it doesn't have are left out). Always show feesNote next to a price: third-party court and county
fees are added per check.
| Field | Type | Example | Meaning |
|---|---|---|---|
level |
integer, 1 to 6 | 2 |
Send it as level when ordering. |
name |
string | Standard |
Product name. |
description |
string | National criminal database, county search, SSN trace. |
What it covers. |
price |
number (USD) or null | 34.95 |
Base price; null if not available. Prices can differ per church. |
feesNote |
string | Plus pass-through fees. |
Always this text. |
curl https://dev.partners.cbgc.io/clients/org-1042/products -H "X-Api-Key: pk_test_YOUR_KEY"
[
{ "level": 1, "name": "Basic", "description": "National criminal database.", "price": 19.95, "feesNote": "Plus pass-through fees." },
{ "level": 2, "name": "Standard", "description": "National criminal database, county search, SSN trace.", "price": 34.95, "feesNote": "Plus pass-through fees." }
]
502 upstream_unavailable on either route: safe to retry.
6. Ordering a check
POST /clients/{clientRef}/checks orders one check. It is billable once submitted. We email the applicant to collect
their details and consent.
| Field | Type | Required | Limits / format | Example | Meaning |
|---|---|---|---|---|---|
firstName |
string | yes | Jordan |
Applicant's first name. | |
lastName |
string | yes | Kim |
Applicant's last name. | |
applicantEmail |
string | yes | jordan.kim@example.com |
The invitation goes here; also used to find duplicates (case-insensitive). | |
applicantPhone |
string | no | 10-digit US or Canadian mobile number, any format | 801-555-1234 |
When sent, the invitation is also texted here. Never stored by us. |
level |
integer | yes | 1 to 6 | 2 |
What to order (chapter 5). |
externalIdentifier |
string | strongly recommended | ≤ 100 characters; unique across all your clients | vol-8841 |
Your id for the check. Reuse it on retries. |
orderedBy.email / .firstName / .lastName |
string | no | ≤ 200 characters each | dana@gracecommunity.example |
Who asked. Written to the order's notes in the church's portal only; never stored by us and never creates a login. |
jobLocation, stateOfEmployment |
string | when the level requires them | ≤ 100 characters; a US state code or name | Blue Springs, MO, MO |
Where the applicant will work, as the church tells you. Some levels require both; send both or neither. We pass them on unchanged and never fill them in. |
forceReorder |
boolean | no | default false |
false |
Order even if a duplicate exists. Billed as a new check. |
Your CHMS must collect each applicant's job location and state of employment. Ask the church, for every person it screens, where that person will work (
jobLocation, e.g.Blue Springs, MO) and in which state (stateOfEmployment), and send both with the order. They decide which state's screening rules apply, and some levels refuse an order without them. Use only what the church tells you: never fill them in from the church's own address or any other guess, since a wrong jurisdiction puts false information on a consumer report order. Batches take the same pair (chapter 7).
curl -X POST https://dev.partners.cbgc.io/clients/org-1042/checks \
-H "X-Api-Key: pk_test_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"externalIdentifier":"vol-8841","firstName":"Jordan","lastName":"Kim","applicantEmail":"jordan.kim@example.com",
"level":2,"orderedBy":{"email":"dana@gracecommunity.example","firstName":"Dana","lastName":"Lopez"}}'
// Node 20+
async function orderCheck(clientRef, person) {
const res = await fetch(`${BASE}/clients/${encodeURIComponent(clientRef)}/checks`, {
method: "POST",
headers: { "X-Api-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
externalIdentifier: person.id, // your id: reuse it on every retry
firstName: person.firstName,
lastName: person.lastName,
applicantEmail: person.email,
level: 2,
}),
});
const body = await res.json();
if (res.status === 201) return body; // the new check
if (res.status === 409 && body.code === "duplicate_check") return body.order; // it already exists
if (res.status === 502 && !body.code) return body; // PENDING: resolves on its own, don't force
throw new Error(`order failed: ${res.status} ${body.code}`);
}
201 Created (with a Location header):
{
"id": "chk_9c2e4f6a1b3d4e5f8a7b6c5d", "externalIdentifier": "vol-8841",
"clientId": "cl_5b0e6f2a8c1d4e7b9a3f2d6c", "status": "CREATED", "stage": "awaiting_applicant", "level": 2,
"expirationDate": null, "completedAt": null, "createdAt": "2026-10-16T15:10:00Z", "updatedAt": "2026-10-16T15:10:01Z"
}
Duplicate rules. Before ordering we look for an existing check with the same externalIdentifier (across all your
clients), or the same applicant email at this client:
| Existing check | Result |
|---|---|
QUEUED, PENDING or CREATED |
409 duplicate_check, existing check in order; nothing billed |
CLEAR or REVIEW, not yet expired |
409 duplicate_check; nothing billed |
CLEAR or REVIEW, expired |
Ordered again (same check id, new order) |
ERROR or CANCELLED |
Ordered again (same check id, new order) |
Same externalIdentifier on a check of another of your clients |
409 external_identifier_in_use; nothing ordered. Use a different id. |
| None | Ordered |
forceReorder: true skips the first two rows and is billed as a new check. A re-order reuses the check's row: same id,
and status goes back to PENDING then CREATED.
Other outcomes.
422 level_not_available: the church has no product for that level. Contact support.502with a check body (statusPENDING, nocode): the order was sent but its answer was lost. Don't retry withforceReorder. APENDINGcheck is resolved automatically within about an hour (chapter 8), and you getcheck.updated. Retrying with the sameexternalIdentifieris always safe.502 upstream_unavailable(problem details): nothing was created; retry.- A check has its
idfrom the moment it is ordered, even whileQUEUEDorPENDING.
Other check routes. GET /clients/{clientRef}/checks/{checkRef} reads one check. POST .../{checkRef}/cancel cancels it
(cancel before the applicant submits to avoid charges; upstreamCancelled: false means the provider didn't confirm, so
contact support). POST .../{checkRef}/replay-webhook queues a new check.updated with the current state.
7. Ordering in bulk
POST /clients/{clientRef}/checks/batch orders up to 500 checks in one call. Each submitted check is billable.
| Field | Type | Required | Limits / format | Example | Meaning |
|---|---|---|---|---|---|
level |
integer | yes | 1 to 6 | 2 |
What to order for everyone. |
batchReference |
string | recommended | ≤ 100 characters | onboarding-2026-10-16 |
Idempotency key: the same value again returns the first batch. |
orderedBy |
object | no | each field ≤ 200 characters | As for single orders. | |
applicants |
array | yes | 1 to 500 items | The people. | |
applicants[].firstName, .lastName |
string | yes | Jordan, Kim |
Applicant's name. | |
applicants[].email |
string | yes | valid email, ≤ 254 characters | jordan.kim@example.com |
The invitation goes here. |
applicants[].phone |
string | no | as applicantPhone |
801-555-1234 |
When sent, the invitation is also texted here. An invalid number makes the item invalid. |
applicants[].externalIdentifier |
string | recommended | ≤ 100 characters, unique across your clients | vol-8841 |
Your id for that check. |
jobLocation, stateOfEmployment |
string | when the level requires them | as for single orders | Blue Springs, MO, MO |
Where everyone works, for applicants without their own. |
applicants[].jobLocation, .stateOfEmployment |
string | no | as for single orders | Overland Park, KS, KS |
This applicant's own; replaces the batch's pair. Half a pair makes the item invalid. |
curl -X POST https://dev.partners.cbgc.io/clients/org-1042/checks/batch \
-H "X-Api-Key: pk_test_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"level":2,"batchReference":"onboarding-2026-10-16",
"applicants":[{"firstName":"Jordan","lastName":"Kim","email":"jordan.kim@example.com","externalIdentifier":"vol-8841"},
{"firstName":"Sam","lastName":"Ortiz","email":"sam.ortiz@example","externalIdentifier":"vol-8842"}]}'
// Node 20+
const res = await fetch(`${BASE}/clients/org-1042/checks/batch`, {
method: "POST",
headers: { "X-Api-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ level: 2, batchReference: "onboarding-2026-10-16", applicants }),
});
const batch = await res.json(); // 202: new batch, 200: the batch you already sent with this batchReference
for (const item of batch.items) {
if (item.result === "invalid") console.warn(item.index, item.errors);
}
202 Accepted (or 200 OK for a repeated batchReference):
{
"batchId": "3f1c0b9e-7a52-4a6e-9d0c-5e7d2b1a8c44", "batchReference": "onboarding-2026-10-16",
"clientId": "cl_5b0e6f2a8c1d4e7b9a3f2d6c", "status": "processing",
"counts": { "QUEUED": 1, "PENDING": 0, "CREATED": 0 },
"items": [
{ "index": 0, "externalIdentifier": "vol-8841", "result": "queued", "check": { "id": "chk_9c2e4f6a1b3d4e5f8a7b6c5d", "status": "QUEUED", "stage": "queued" } },
{ "index": 1, "externalIdentifier": "vol-8842", "result": "invalid", "errors": { "email": ["email is not a valid email address."] } }
],
"createdAt": "2026-10-16T15:10:00Z"
}
(check is the full check; shortened here.)
result |
Meaning | Billed? |
|---|---|---|
queued |
Accepted; will be submitted in the background. check is the new (or re-ordered) check. |
Yes, once submitted |
duplicate |
The applicant already has a current check (same rules as chapter 6), or appears earlier in this batch. check is that check. |
No |
invalid |
The applicant's data was rejected, or its externalIdentifier belongs to another client's check. See errors. |
No |
- Problems with single applicants never fail the batch. The batch itself is
400 validation_failedonly for: no applicants, more than 500, a missing or invalidlevel, or a batch field too long. 409 batch_in_progress: the client already has a running batch. Send yours once that one iscompleted.422 level_not_availableand502 upstream_unavailable: nothing was ordered; see chapter 10.- Queued checks are submitted four at a time. Each sends
check.updatedas it movesQUEUED→PENDING→CREATED. batch.completedfires once no check isQUEUEDorPENDING: everything is submitted, not finished. Results come ascheck.updated.GET /batches/{batchId}returns the same shape.statusisprocessingorcompleted;countsalways hasQUEUED,PENDINGandCREATED, plus any other status present.
8. Tracking status
Each check has a contract-stable status and a finer stage:
status |
Final? | stage |
Meaning |
|---|---|---|---|
QUEUED |
no | queued |
In a batch, not yet sent. |
PENDING |
no | submitting |
Being sent, or sent with its answer lost (resolved automatically, see below). |
CREATED |
no | awaiting_applicant |
Submitted; the applicant has been invited and hasn't finished the form yet. |
CREATED |
no | applicant_ready |
The applicant finished the form, but the order is held for the church to review and release in its portal (the portal's Applicant Ready queue). Usually a possible duplicate or a jurisdiction question. The church has to act; see below. |
CREATED |
no | in_progress |
The applicant finished the form (and, if it was held, the church released it); the searches are running. |
CLEAR |
yes | complete |
Completed, and no search found records (nor did the church's decision, if it uses one, fail the applicant). |
REVIEW |
yes | complete |
Completed and flagged: a search found records, or the church's decision didn't pass. The church reviews it in its portal. Also used if the result couldn't be read, so a check is never shown clear by mistake. |
ERROR |
yes | closed |
Could not be completed, or never reached the screening provider. errorCode and errorMessage say why (below). Ordering again is allowed. |
CANCELLED |
yes | closed |
Cancelled. Ordering again is allowed. |
Every move between CREATED stages sends check.updated. A stage never goes back to awaiting_applicant once the
applicant has submitted.
The life of a check
- You order it.
POST /clients/{clientRef}/checks: the check isPENDING(submitting) for a moment, thenCREATED(awaiting_applicant). The applicant gets an email from Datasource, and a text too if you sentapplicantPhone, with a link to a secure form. - The applicant fills in the form. They give their details (date of birth, Social Security number, addresses) and consent on Datasource's form, never in your app. The invitation link expires after a set number of days.
- The order is either released automatically or held.
- Most orders go straight to the searches: the stage becomes
in_progress. - Some are held in the church's Applicant Ready queue: the stage becomes
applicant_ready. Usually this is a possible duplicate (the applicant already has a check) or a jurisdiction question. Someone at the church signs in to its Datasource portal (portalUrlon the client), reviews the order, may choose which counties to search (this can change the price), and releases it. We cannot release it from the API, because it is the church's decision and can change what it pays. Once released, the stage becomesin_progress.
- Most orders go straight to the searches: the stage becomes
- The searches run. This can take from minutes to several days, depending on the searches and the courts. The
check stays
CREATED(in_progress). - The result. The check becomes
CLEAR(no search found records) orREVIEW(something was found, or the church's decision didn't pass), stagecomplete. The report itself stays in the church's portal; only the outcome is in the API.
Show applicant_ready to the church's staff. Nothing moves until someone at the church releases the order, so
show it as needing action, e.g. "Action needed: review and release this check in your Datasource portal", with a link
to the client's portalUrl.
CLEAR is reported only when the screening provider says no search found records; any other completed result is
REVIEW (flagged), so a person always looks at it. Results themselves stay in the church's portal; they are not in the API.
completedAt is set when a check reaches a final status: the screening provider's own completion time when it gives one. expirationDate is set for CLEAR and REVIEW only: completedAt plus the client's checkValidityYears (1 to 5): its own (PUT /clients/{clientRef}/settings), else your account's default (PUT /settings, default 2). Until then, ordering the same applicant again is refused as a duplicate unless you send forceReorder.
Per-church validity. Each church can keep results for its own number of years:
curl -X PUT https://dev.partners.cbgc.io/clients/org-1042/settings -H "X-Api-Key: pk_test_YOUR_KEY" \
-H "Content-Type: application/json" -d '{"checkValidityYears": 3}'
The answer has the church's own value (checkValidityYears, null when it uses your default) and the one that applies (effectiveCheckValidityYears). Send null to go back to your account's default. GET /clients/{clientRef}/settings reads it. A change applies to checks completed after it.
The report, and what it cost. A CLEAR or REVIEW check has reportUrl: the report in the church's Datasource
portal. It opens only after the church's own sign-in, so it is safe to show to staff, but it's no use to anyone else.
A finished check also has charges: what the screening provider charged for it, in US dollars (0 for free test
personas). Both are null until then.
Re-sending the invitation. Applicants lose emails. POST /clients/{clientRef}/checks/{checkRef}/remind sends the
invitation again (and the text, if you sent applicantPhone), for a check waiting for the applicant (CREATED, stage
awaiting_applicant), at most once an hour (409 reminder_too_soon otherwise; 409 check_not_awaiting_applicant once
the applicant has finished). An invitation that expires before the applicant finishes closes the check as ERROR
(invitation_expired); order again to send a new one.
Expiry reminders. 30 days and again 7 days before a CLEAR or REVIEW check's expirationDate you get
check.expiring (chapter 9), so you can prompt the church to re-screen before the clearance lapses.
Why a check failed. An ERROR check, and its check.updated, carry errorCode (stable, for your code) and
errorMessage (one sentence to show the church's staff). Both are null for every other status, and ordering again
clears them.
errorCode |
Meaning | What to do |
|---|---|---|
missing_job_location |
The level needs the applicant's job location and state of employment. | Send jobLocation and stateOfEmployment, order again. |
missing_information |
The screening provider needs other details; errorMessage names them. |
Add them, order again. |
invalid_applicant_details |
The applicant's phone number or email was refused. | Fix applicantPhone or applicantEmail, order again. |
provider_rejected |
The screening provider refused the order for another reason. | Check the applicant's details; contact support with the check's id. |
not_submitted |
The order never reached the screening provider. Nothing was charged. | Order again. |
provider_error |
The screening provider couldn't complete the check. | Contact support with the check's id. |
invitation_expired |
The applicant didn't finish the form before the invitation expired. The order was cancelled. | Order again to send a new invitation. |
New codes may be added; treat an unknown one like provider_rejected. We never pass on the screening provider's own
error text, which can contain the applicant's details.
Allowed transitions:
| From | To | When |
|---|---|---|
| (new) | QUEUED |
Accepted in a batch. |
| (new) | PENDING → CREATED |
A single order is submitted. |
QUEUED |
PENDING, then CREATED |
The batch submits it. |
QUEUED |
ERROR |
It couldn't be sent. |
PENDING |
CREATED, CLEAR, REVIEW, ERROR, CANCELLED |
The provider confirms it (live, or found by the automatic lookup). |
PENDING |
ERROR |
After about an hour, the provider has no such order: it was never submitted. |
CREATED |
CLEAR, REVIEW, ERROR, CANCELLED |
The check finishes, or is cancelled. |
| any | CANCELLED |
You cancel it. |
ERROR, CANCELLED, or expired CLEAR/REVIEW |
PENDING or QUEUED |
You order again for the same applicant (same id). |
PENDING resolves on its own. Every 10 minutes we look up checks that have been PENDING for 15 minutes or more with
the screening provider. Found: the check gets its real status. Not found after an hour: it becomes
ERROR. Either way you get check.updated, and the check's batch can complete.
Webhooks or polling. Use webhooks for speed (chapter 9) and a nightly reconciliation for certainty. Both give the same check objects.
Reconciliation recipe (run nightly, per connected church):
- Keep
lastSyncper church (start with the church's connect time). GET /clients/{clientRef}/checks?updated_since={lastSync}&limit=500.- For each item, upsert by
id, keeping the newerupdatedAt. - While
nextCursorisn't null, repeat with&cursor={nextCursor}. A check can appear on two pages; upserting byidhandles it. - Set
lastSyncto the time you started step 2.
// Node 20+
async function reconcile(clientRef, since) {
let cursor = null;
do {
const url = new URL(`${BASE}/clients/${encodeURIComponent(clientRef)}/checks`);
url.searchParams.set("updated_since", since);
url.searchParams.set("limit", "500");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { "X-Api-Key": API_KEY } });
if (!res.ok) throw new Error(`reconcile failed: ${res.status}`);
const page = await res.json();
for (const check of page.items) await upsertCheck(check); // your storage, keyed by check.id
cursor = page.nextCursor;
} while (cursor);
}
curl "https://dev.partners.cbgc.io/clients/org-1042/checks?updated_since=2026-10-15T00:00:00Z&limit=500" -H "X-Api-Key: pk_test_YOUR_KEY"
limit defaults to 100 and is at most 500. updated_since without an offset is UTC. A bad cursor is 400 invalid_cursor.
9. Webhooks
Register with PUT /settings. It replaces both URL values, so send the full set:
| Field | Type | Required | Limits / format | Example | Meaning |
|---|---|---|---|---|---|
webhookUrl |
string or null | no | https:// URI, ≤ 2048 characters, host must resolve only to public addresses |
https://app.example.com/cbc/webhooks |
Where events go. Empty or omitted turns webhooks off. |
connectReturnUrls |
string array | no | ≤ 10 entries, each an https:// URI |
["https://app.example.com/cbc/connect-done"] |
Allowed returnUrls. Omitted clears them. |
checkValidityYears |
integer | no | 1 to 5; default 2 | 2 |
Your account's default for how many years a CLEAR or REVIEW check stays valid (its expirationDate is completedAt plus this), for churches without their own (PUT /clients/{clientRef}/settings). Omitted keeps the current value. A change applies to checks completed after it. |
Delivery log. GET /settings/webhook-deliveries lists every attempt to deliver a webhook to you in the last 30
days, newest first: the event, its eventId, the check, your endpoint's HTTP status, how long it took, and which
attempt it was. Filter with eventId or checkId; limit is 1 to 200 (default 50). Bodies are never stored, ours or
yours; to get a check's current state again, use replay-webhook.
GET /settings reads them back. POST /settings/webhook-secret rotates the signing secret: the new one is returned once
and the old one stops working immediately, so rotate when quiet. POST /settings/webhook-test sends a signed ping
(400 validation_failed if no webhook URL is registered).
Events. Every event is a POST with Content-Type: application/json, a flat JSON object with event, eventId and
timestamp, signed in X-Partner-Signature.
event |
When | Extra fields |
|---|---|---|
check.updated |
A check changed status (or you called replay-webhook) | the check: id, externalIdentifier, clientId, status, stage, level, expirationDate, completedAt, reportUrl, charges, errorCode, errorMessage |
check.expiring |
A CLEAR or REVIEW check expires in 30 days, then again in 7 (each once) |
id, externalIdentifier, clientId, status, expirationDate, daysLeft |
batch.completed |
No check of a batch is QUEUED or PENDING |
batchId, batchReference, clientId, externalClientId, counts |
client.connected |
A church finished the connect page | sessionId, externalClientId, clientId, status (Active) |
client.migrated |
An existing Datasource church was moved under you | externalClientId, clientId, importedChecks |
ping |
You called POST /settings/webhook-test |
none |
{
"event": "check.updated", "eventId": "e7a1c3b5-2d4f-4a6b-8c9d-0e1f2a3b4c5d", "id": "chk_9c2e4f6a1b3d4e5f8a7b6c5d",
"externalIdentifier": "vol-8841",
"clientId": "cl_5b0e6f2a8c1d4e7b9a3f2d6c", "status": "CLEAR", "stage": "complete", "level": 2,
"expirationDate": "2028-10-18T18:22:41Z", "completedAt": "2026-10-18T18:22:41Z", "timestamp": "2026-10-18T18:22:42Z"
}
Verify every delivery before trusting it. The signature is the lowercase hex HMAC-SHA256 of the raw request body
(the exact bytes, before parsing), keyed with the UTF-8 bytes of your whole webhook secret (whsec_ prefix included).
Compare in constant time.
// Node 20+, no libraries
import http from "node:http";
import crypto from "node:crypto";
const SECRET = process.env.DATASOURCE_WEBHOOK_SECRET; // whsec_YOUR_SECRET
function isValidSignature(rawBody, header) {
const expected = Buffer.from(crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex"), "utf8");
const given = Buffer.from(header ?? "", "utf8");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks);
if (!isValidSignature(raw, req.headers["x-partner-signature"])) { res.writeHead(401).end(); return; }
const event = JSON.parse(raw.toString("utf8"));
res.writeHead(200).end(); // answer fast...
queueForProcessing(event); // ...then work asynchronously; skip it if event.eventId was already seen
});
}).listen(3000);
# Python 3
import hashlib, hmac, os
def is_valid_signature(raw_body: bytes, header: str | None) -> bool:
expected = hmac.new(os.environ["DATASOURCE_WEBHOOK_SECRET"].encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
// C#
using System.Security.Cryptography;
using System.Text;
static bool IsValidSignature(byte[] rawBody, string? header, string secret)
{
var expected = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), rawBody)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(header ?? ""));
}
Delivery rules.
- Any
2xxwithin 15 seconds is success. Anything else, a timeout, or a connection error is retried up to 5 more times with increasing delays (seconds at first, then minutes). Redirects are not followed. After the last retry the event is dropped; your reconciliation (chapter 8) orPOST .../{checkRef}/replay-webhookcovers it. - Delivery is at least once: drop repeats by
eventId. A retry resends the identical body (sameeventIdandtimestamp). A replay is a new event with a neweventId. - Delivery is not ordered: for each check (key by
id) keep the state with the latesttimestamp, or re-read the check withGET /clients/{clientRef}/checks/{checkRef}. - The signature covers only the body. For replay protection, also reject events whose
timestampis far in the past (for example more than a day). - Answer
401for a bad signature; it will be retried, which is harmless. - Key checks by
id: every check has one from the moment it is ordered.
10. Errors
Errors are RFC 9457 problem details (application/problem+json) with a stable
code and a type link to its entry in the error catalog. Branch on the
HTTP status, then code.
{
"type": "https://partners.cbgc.io/docs/errors.md#validation_failed",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": { "level": ["level is required."] },
"code": "validation_failed"
}
Two bodies aren't problem details: 409 duplicate_check on ordering (the existing check is in order) and 502 on
ordering when the body is the PENDING check.
code |
HTTP | Retry the same request? | What to do |
|---|---|---|---|
validation_failed |
400 | No | Fix each field named in errors. |
invalid_cursor |
400 | No | Start again from the first page. |
invalid_return_url |
400 | No | Register the URL with PUT /settings. |
invalid_json |
400 | No | Send valid JSON with values of the right type. |
invalid_api_key |
401 | No | Use a valid key for this environment. |
client_not_found, check_not_found, batch_not_found, session_not_found |
404 | No | Check the id; only your own objects resolve. |
not_found / method_not_allowed |
404 / 405 | No | Check the path and method. |
duplicate_check |
409 | No | Use the check in order; forceReorder only for a new billed check. |
external_client_id_in_use |
409 | No | Use the existing client. |
client_already_connected |
409 | No | The church is already connected. |
external_identifier_in_use |
409 | No | Use a different externalIdentifier. |
batch_in_progress |
409 | Yes, later | Wait for the running batch to complete. |
check_not_awaiting_applicant |
409 | No | Only a check waiting for the applicant can be re-sent. |
reminder_too_soon |
409 | Yes, later | Wait an hour after the last reminder. |
payload_too_large / unsupported_media_type |
413 / 415 | No | Send less / send application/json. |
level_not_available |
422 | No | Pick an available level or contact support. |
rate_limited |
429 | Yes, after Retry-After |
Wait, then retry. |
internal_error |
500 | Yes, same externalIdentifier |
Retry with backoff. Quote the traceId to support. |
upstream_unavailable |
502 | Usually | Retry with backoff; for orders see chapter 6. |
Every entry, with details: /docs/errors.md.
11. Testing in the sandbox
The test environment (https://dev.partners.cbgc.io, pk_test_ keys) runs every flow against a sandbox: nothing is
charged and no real check is run. Applicants don't finish on their own there, so the test environment has an extra
route that moves a check to any status. It exists only in the test environment, and the test environment's copy of
this guide (https://dev.partners.cbgc.io/docs/guide.md) describes it.
Test plan (run it end to end in the test environment before asking for a production key):
GET /statusanswers200; a wrong key answers401 invalid_api_key.PUT /settings, thenPOST /settings/webhook-test: your endpoint gets apingand verifies its signature.- Connect a church through the page (choose invoice). Confirm with
client.connectedandGET /connect/sessions/{id}. - Start a second session and cancel it on the page: the browser comes back with
state=cancelled. - Order a check; then order it again with the same
externalIdentifier: the second is409 duplicate_check. - Move three checks to
CLEAR,REVIEWandERROR(test environment route, above); your app shows each and drops a repeatedeventId. - Order a batch of three with one bad email: one
invaliditem, andbatch.completedonce the others areCREATED. - Send the same
batchReferenceagain:200with the samebatchId. - Run your reconciliation job: it finds every check from steps 5 to 7.
12. Going live
- [ ] Your webhook endpoint verifies
X-Partner-Signature, answers within 15 seconds, and drops repeatedeventIds. - [ ] You branch on HTTP status and
code, never ontitleordetail. - [ ] Every order sends an
externalIdentifierand retries reuse it; a502on ordering never triggersforceReorder. - [ ] You confirm connections with
client.connectedorGET /connect/sessions/{sessionId}, never the redirect alone. - [ ] A nightly reconciliation job runs for every connected church.
- [ ] Prices are shown with
feesNote. - [ ] You have your production key (
pk_live_...) and webhook secret from Datasource, stored in a secret manager. - [ ] On production:
PUT /settingswith your production webhook and return URLs, thenPOST /settings/webhook-test.
13. Existing Datasource churches
A church that already uses Datasource can choose Already a Datasource customer? on the connect page. Its browser comes back with
state=migration_requested, and GET /connect/sessions/{sessionId} shows migration_requested. We verify the request
by hand with the contact we already have on file, then move the church's account under yours. It keeps its logins,
billing and reports.
When the move and the import of its past checks are done, you get client.migrated with externalClientId, clientId
and importedChecks. From then on the church works like any other client. Until that event, treat the church as not
yet connected.
Linking past checks to your people. Imported checks have no externalIdentifier. Find them by email:
POST /clients/{clientRef}/checks/lookup with { "emails": [...] } (1 to 500 addresses, each ≤ 254 characters) returns
the latest check per email, keyed by the email's position (index); emails are never returned. Then set your id once
with PATCH /clients/{clientRef}/checks/{checkRef} and { "externalIdentifier": "vol-8841" } (409 external_identifier_in_use if the check already has one or another check uses it). Ordering again for a person with a
current imported check is 409 duplicate_check, as for any check.
14. Limits
| What | Limit |
|---|---|
externalIdentifier, externalClientId, batchReference |
≤ 100 characters |
churchName, each prefill field, each orderedBy field |
≤ 200 characters |
| Batch applicants | 1 to 500 per batch |
| Batch applicant email | valid address, ≤ 254 characters |
| Running batches | 1 per client at a time (a batch counts as running for at most 24 hours) |
| Batch submission | 4 checks at a time |
| Lookup emails | 1 to 500 per call, each ≤ 254 characters |
GET .../checks page size |
default 100, maximum 500 |
connectReturnUrls |
≤ 10 URLs, https:// only |
webhookUrl |
https://, ≤ 2048 characters, public addresses only |
| Connect link | opened within 15 minutes, once |
| Connect session | ends 60 minutes after opening; locked after 10 failed submits or 3 failed billing attempts |
| Connect sessions created | 30 per minute per partner (429, Retry-After: 60) |
| Invalid API key | 20 per minute per IP address, then 429 for 60 seconds |
| Webhook response time | 15 seconds |
| Webhook retries | up to 5 more attempts |
PENDING resolution |
checked after 15 minutes, every 10 minutes; released as ERROR after 1 hour |
| Connect sessions kept | 7 days after they end |
15. Versioning and changes
- Changes are additive: new routes, new fields, new enum values and new webhook events can appear at any time.
Ignore what you don't know, and don't fail on an unknown
status,stage,stateorevent. - A breaking change gets 90 days' notice in the changelog and by email.
operationIds in/openapi/v1.jsonare stable; generate a client from it if you like.
16. Compliance
Datasource is the consumer reporting agency. The church is the end user: it makes, and is responsible for, every decision based on a report, including adverse action, which it handles in its portal. Applicants give their details and consent directly to Datasource; you never handle SSNs or dates of birth, and you never see card data.