Skip to content
Datasource Background Check Partner API

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):

  1. Check your key:

    curl https://dev.partners.cbgc.io/status -H "X-Api-Key: pk_test_YOUR_KEY"
    
  2. 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"]}'
    
  3. Connect a church (chapter 4): POST /connect/sessions, then open the returned connectUrl in a browser and fill in the page (choose invoice to finish without a card).

  4. See what you can order: GET /products (chapter 5).

  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.io and a live key only on https://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 /status answers 200 for 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 the Retry-After seconds (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:

  1. When the church's admin clicks your "Connect to Datasource" button, your server calls POST /connect/sessions.
  2. 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.
  3. 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).
  4. 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.
  5. We send the browser to {returnUrl}?session_id={sessionId}&state={state} and send you client.connected.
  6. Confirm with the client.connected webhook or GET /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 email 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.
  • 502 with a check body (status PENDING, no code): the order was sent but its answer was lost. Don't retry with forceReorder. A PENDING check is resolved automatically within about an hour (chapter 8), and you get check.updated. Retrying with the same externalIdentifier is always safe.
  • 502 upstream_unavailable (problem details): nothing was created; retry.
  • A check has its id from the moment it is ordered, even while QUEUED or PENDING.

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_failed only for: no applicants, more than 500, a missing or invalid level, or a batch field too long.
  • 409 batch_in_progress: the client already has a running batch. Send yours once that one is completed.
  • 422 level_not_available and 502 upstream_unavailable: nothing was ordered; see chapter 10.
  • Queued checks are submitted four at a time. Each sends check.updated as it moves QUEUED → PENDING → CREATED.
  • batch.completed fires once no check is QUEUED or PENDING: everything is submitted, not finished. Results come as check.updated.
  • GET /batches/{batchId} returns the same shape. status is processing or completed; counts always has QUEUED, PENDING and CREATED, 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

  1. You order it. POST /clients/{clientRef}/checks: the check is PENDING (submitting) for a moment, then CREATED (awaiting_applicant). The applicant gets an email from Datasource, and a text too if you sent applicantPhone, with a link to a secure form.
  2. 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.
  3. 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 (portalUrl on 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 becomes in_progress.
  4. The searches run. This can take from minutes to several days, depending on the searches and the courts. The check stays CREATED (in_progress).
  5. The result. The check becomes CLEAR (no search found records) or REVIEW (something was found, or the church's decision didn't pass), stage complete. 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):

  1. Keep lastSync per church (start with the church's connect time).
  2. GET /clients/{clientRef}/checks?updated_since={lastSync}&limit=500.
  3. For each item, upsert by id, keeping the newer updatedAt.
  4. While nextCursor isn't null, repeat with &cursor={nextCursor}. A check can appear on two pages; upserting by id handles it.
  5. Set lastSync to 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 2xx within 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) or POST .../{checkRef}/replay-webhook covers it.
  • Delivery is at least once: drop repeats by eventId. A retry resends the identical body (same eventId and timestamp). A replay is a new event with a new eventId.
  • Delivery is not ordered: for each check (key by id) keep the state with the latest timestamp, or re-read the check with GET /clients/{clientRef}/checks/{checkRef}.
  • The signature covers only the body. For replay protection, also reject events whose timestamp is far in the past (for example more than a day).
  • Answer 401 for 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):

  1. GET /status answers 200; a wrong key answers 401 invalid_api_key.
  2. PUT /settings, then POST /settings/webhook-test: your endpoint gets a ping and verifies its signature.
  3. Connect a church through the page (choose invoice). Confirm with client.connected and GET /connect/sessions/{id}.
  4. Start a second session and cancel it on the page: the browser comes back with state=cancelled.
  5. Order a check; then order it again with the same externalIdentifier: the second is 409 duplicate_check.
  6. Move three checks to CLEAR, REVIEW and ERROR (test environment route, above); your app shows each and drops a repeated eventId.
  7. Order a batch of three with one bad email: one invalid item, and batch.completed once the others are CREATED.
  8. Send the same batchReference again: 200 with the same batchId.
  9. 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 repeated eventIds.
  • [ ] You branch on HTTP status and code, never on title or detail.
  • [ ] Every order sends an externalIdentifier and retries reuse it; a 502 on ordering never triggers forceReorder.
  • [ ] You confirm connections with client.connected or GET /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 /settings with your production webhook and return URLs, then POST /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, state or event.
  • A breaking change gets 90 days' notice in the changelog and by email.
  • operationIds in /openapi/v1.json are 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.