AI Assistant & integrations

API & webhooks (developers)

Order checks, receive check and adverse-action events programmatically.

Settings → API — create keys for the REST API and webhooks. · click to enlarge

VolunteerBadge has a simple REST API for managing volunteers, sending applications, ordering background checks, and receiving real-time events. If you'd rather not write code, see Import volunteers for the spreadsheet importer, or Connect to Claude to drive everything in plain English.

Base URL

All endpoints live under https://www.volunteerbadge.com/api/v1. Requests and responses are JSON.

Authentication

Create an API key in Settings → API(you'll accept the CRA End-User Agreement the first time). Keys look like vb_live_… — treat them like a password and never expose one in client-side code. Send it as a bearer token on every request:

Authorization: Bearer vb_live_xxxxxxxxxxxxxxxxxxxx

Identity verification required first (live keys)

Before you can create a live API key or make a live API call, an owner or admin on the account must complete a free, one-time identity verification in Settings → Account (a quick government-ID + selfie check, about two minutes). Until then, live requests return 403 identity_verification_required. Sandbox test keys are exempt — see below — so you can start building right away.

Fair use & abuse monitoring

API and MCP traffic is rate-limited (100 requests per minute per key) and monitored for abuse — key guessing, volume spikes, and sustained error storms are flagged automatically. Abusive access may be suspended while we investigate; suspended requests return 403 api_access_suspended. Questions? support@screenforgelabs.com.

Sandbox / test mode

Build and test your whole integration — including webhooks — before going live, using a sandbox key. In Settings → API, click “Create sandbox test key”. Sandbox keys start with vb_test_ (live keys start with vb_live_) and can be created with no agreement and no identity verification.

A test key hits the same endpoints with the same request shape. In sandbox:

  • No credit is charged and no real check runs — nothing reaches the screening vendor.
  • You get an instant simulated response, and your registered webhooks fire for real (check.complete / check.error, and volunteer.created when you create a volunteer), so you can validate your handler end-to-end.
  • Nothing is persisted — POST /api/v1/volunteers, PATCH /api/v1/volunteers/{id}, POST /api/v1/volunteers/{id}/rescreen, and POST /api/v1/applications return simulated IDs without writing a roster row or sending an invite.

The outcome is deterministic from the subject you send (like a test card number):

Send thisSimulated result
lastName: "Records"Records found — result "consider" (fires check.complete)
SSN ending 0000Same — records found
lastName: "Error"The check fails (fires check.error)
anything elseClear result (fires check.complete)

Sandbox check IDs are prefixed chk_test_, and every sandbox response includes "mode": "test". When you're ready for production, generate a vb_live_ key (that one requires the CRA agreement + identity verification, since it furnishes real consumer reports) and run the exact same code.

Endpoints

Method & pathWhat it does
GET /api/v1/volunteersList volunteers (paginated, filter by status). Includes rescreenDueAt.
GET /api/v1/volunteers/{id}Get one volunteer, including rescreenDueAt and their checks.
POST /api/v1/volunteersCreate a volunteer (idempotent on email).
PATCH /api/v1/volunteers/{id}Archive or restore (status "archived" or "approved"). Archived volunteers are excluded from auto-rescreen.
POST /api/v1/volunteers/{id}/rescreenSend the short FCRA re-screen consent link (not a full application).
GET /api/v1/templatesList your application templates — where the templateId comes from.
POST /api/v1/applicationsSend a volunteer application by email, SMS, or shareable link.
POST /api/v1/checksOrder a background check for a subject (name + DOB + SSN + address).
POST /api/v1/checks/instantCertified no-SSN instant check (name + DOB + state) — requires authorizationOnFile + permissiblePurpose.
GET /api/v1/checks/{id}Get a check’s status and result.
GET /api/v1/creditsGet your remaining check-credit balance.

Create a volunteer

Push a person into VolunteerBadge — e.g. migrating a roster or syncing from another system. Posting the same email twice returns the existing record instead of duplicating it.

curl -X POST https://www.volunteerbadge.com/api/v1/volunteers \
  -H "Authorization: Bearer vb_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Jamie",
    "last_name": "Rivera",
    "email": "jamie@example.com",
    "phone": "555-014-8291",
    "dob": "1992-03-14",
    "state": "FL",
    "last_check_date": "2025-06-01",
    "fcra_authorization_on_file": true,
    "status": "approved"
  }'

# → { "volunteerId": "f883...", "created": true }

fcra_authorization_on_file is required

Set it to true only for people whose signed FCRA authorization you actually hold — it's what allows checks (and auto-rescreen) to run for them.

List volunteers

curl "https://www.volunteerbadge.com/api/v1/volunteers?status=approved&page=1&limit=25" \
  -H "Authorization: Bearer vb_live_..."

# → { "volunteers": [ { "id": "...", "first_name": "...", "status": "approved",
#                       "rescreenDueAt": "2026-06-01T00:00:00.000Z", ... } ],
#     "pagination": { "page": 1, "limit": 25, "total": 42, "totalPages": 2 } }

Get one volunteer

curl https://www.volunteerbadge.com/api/v1/volunteers/f883... \
  -H "Authorization: Bearer vb_live_..."

# → { "id": "...", "firstName": "Jamie", "status": "approved",
#     "rescreenDueAt": "2026-06-01T00:00:00.000Z", "checks": [ ... ] }

Archive or restore a volunteer

Archiving removes someone from auto re-screen without deleting records (FCRA retention). Restore with { "status": "approved" } — that restore still requires a completed background check on file, same as the dashboard.

curl -X PATCH https://www.volunteerbadge.com/api/v1/volunteers/f883... \
  -H "Authorization: Bearer vb_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "status": "archived" }'

# → { "id": "f883...", "status": "archived" }

Archive is blocked (HTTP 409) while a decision is pending or FCRA adverse action is incomplete.

Send a re-screen consent link

This emails (default), texts, or returns a link for the short FCRA authorization form — not a full application. The volunteer confirms their address, signs, and the $5 check runs. Prior authorization is not reused. Archived volunteers must be restored first. SMS requires smsConsentAttested: true.

Optional volunteerPays sets who pays for this re-screen. true means the volunteer pays $5. false means your organization spends one credit. Leave it out and the re-screen follows Settings → Screening → “Volunteers pay their own $5 fee.”

curl -X POST https://www.volunteerbadge.com/api/v1/volunteers/f883.../rescreen \
  -H "Authorization: Bearer vb_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "deliveryMethod": "email" }'

# → { "inviteId": "...", "consentUrl": "https://www.volunteerbadge.com/rescreen/...",
#     "deliveryMethod": "email", "expiresAt": "..." }

List application templates

Every application you send is built from one of your templates, and POST /api/v1/applicationsneeds that template's id. This is how you look them up:

curl https://www.volunteerbadge.com/api/v1/templates \
  -H "Authorization: Bearer vb_live_..."

# → { "templates": [
#      { "id": "3f9a1c22-7b64-4e08-9d15-8a2c7e4b1f60",
#        "name": "Youth & Children", "sector": "youth",
#        "isDefault": true, "fieldCount": 14, "sendCount": 37,
#        "createdAt": "...", "updatedAt": "..." } ] }

Your defaulttemplate is returned first, so an integration that just wants “the normal one” can take templates[0].idwithout sorting. Only your own organization's templates are ever returned. The question definitions themselves aren't included — fieldCount is there to tell similar templates apart.

ID formats in sandbox

Sandbox responses return real UUIDs for applicationId and volunteerId, so strict RFC 4122 validation behaves the same in test and production. Sandbox responses are identified by "sandbox": true and "mode": "test", never by the shape of an id.

Two deliberate exceptions: a sandbox check id is prefixed chk_test_ (the simulated outcome is encoded in it, which is what lets GET /api/v1/checks/{id} return a result for a check that was never stored), and a sandbox apply link token is prefixed sbx_. Opening that link shows a labelled sandbox page explaining that the invite worked and nothing was emailed — no real application sits behind it.

Send a volunteer application (consent-first invite)

Emails or texts the applicant your application, so they complete the FCRA disclosure and sign the authorization themselves. This is the path to use when you don't already hold a signed authorization — and the one that supports applicants with no middle name, since they attest to that on the signed form.

curl -X POST https://www.volunteerbadge.com/api/v1/applications \
  -H "Authorization: Bearer vb_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "3f9a1c22-7b64-4e08-9d15-8a2c7e4b1f60",
    "email": "jamie@example.com"
  }'

# → { "applicationId": "...", "applyUrl": "https://www.volunteerbadge.com/apply/<token>",
#     "deliveryMethod": "email", "expiresAt": "2026-09-01T00:00:00.000Z" }

templateId is the only required field. Get it from GET /api/v1/templates above — or, if you just want to paste one in by hand, open Recruit → Applications, click the template to edit it, and take the UUID from the end of the browser URL (/dashboard/applications/builder/<templateId>). A template that doesn't belong to your organization returns 404.

Send it byWhat to postYou get back
EmailInclude email.The invite is emailed; deliveryMethod: "email".
SMSInclude phone.The invite is texted; deliveryMethod: "sms".
Shareable linkPost neither (or deliveryMethod: "link").Nothing is sent — hand out the applyUrl yourself.

Who pays

Optional volunteerPays sets who pays for this invite. true means the volunteer pays $5. false means your organization spends one credit. Leave it out and the invite follows Settings → Screening → “Volunteers pay their own $5 fee.” when the person applies. Anything else returns 400.

The value is stored on that invite, including a one-time link you hand out yourself. A permanent shareable link from the dashboard (the template URL) still has no payer and follows the organization setting.

# Optional — omit volunteerPays to use the organization setting
    "email": "jamie@example.com",
    "volunteerPays": true

Waiving identity verification for one invite

Settings → Screening → “Require identity verification” is an organization-wide default, but a single invite can override it. Use it when you confirm someone's ID another way, e.g. an elderly volunteer whose ID you check in person at intake and who would struggle with the selfie step.

curl -X POST https://www.volunteerbadge.com/api/v1/applications \
  -H "Authorization: Bearer vb_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "3f9a1c22-7b64-4e08-9d15-8a2c7e4b1f60",
    "email": "jamie@example.com",
    "requireIdVerification": false,
    "idVerificationReason": "ID checked in person at intake on 2026-08-18"
  }'

Omit requireIdVerification and the invite inherits your org setting. Set it to true to require verification for one person even when your org default is off. Setting it to false requires idVerificationReason (10 characters or more): waiving the step that ties a real person to the record being screened is worth a note, and we store it on the invite so anyone reviewing that volunteer later can see how their identity was confirmed.

You do not need a second organization or a second API key for this.

deliveryMethod is inferred from what you send

The channel is decided by which contact field is present, not by the deliveryMethod value: post an email and the invite is emailed even if you also pass deliveryMethod: "link". To get a link and send nothing, omit both email and phone. Posting neither a contact field nor deliveryMethod: "link" returns 400.

The returned applyUrlis the same link the applicant receives, so you can show it in your own UI or fall back to it if someone doesn't get the email.

Order a background check

Requires firstName, middleName, lastName, dob, ssn, address, city, state, zip. middleName is required by default— it confirms identity and reduces false matches on common names. This endpoint runs the same SSN trace/address-history lookup as an invited applicant's check. This consumes 1 credit.

curl -X POST https://www.volunteerbadge.com/api/v1/checks \
  -H "Authorization: Bearer vb_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Jamie", "middleName": "Alex", "lastName": "Rivera",
    "dob": "1992-03-14", "ssn": "123456789",
    "address": "1842 Maple Avenue", "city": "Austin",
    "state": "TX", "zip": "78701"
  }'

# → { "checkId": "...", "status": "processing", "result": null, "message": "..." }

Screening scope follows Settings → Screening

Omit includeMisdemeanors and includeTrafficViolations— the recommended default — and the check uses your org's Settings → Screening defaults, the same ones invited volunteer checks use. Pass either flag as true or false to override that one setting for this request only. The flags are independent: you can override one and leave the other to the org default.

# Optional — omit both fields to inherit org settings
    "includeMisdemeanors": false,
    "includeTrafficViolations": true

Subscribe to check.complete (search finished) and check.adjudicated (human review finished, for checks with potential records) and you never need to poll. GET /api/v1/checks/{id} is there whenever you want to read the current state directly.

Omitting middle name on the API

middleName is required on POST /api/v1/checks and POST /api/v1/checks/instant unless you explicitly pass "requireMiddleName": false in the JSON body. That flag is an intentional opt-out — omitting it does not waive the requirement. Do not send placeholders like "N/A" or "NMN"; use the opt-out when you genuinely don't have a middle name.

The dashboard Run background check drawer always requires a middle name (or initial). For someone with no middle name, use the consent-first invite (POST /api/v1/applications) so they can tick “I have no middle name” on the signed application.

Check a check's status

Use the checkId returned by POST /api/v1/checks or POST /api/v1/checks/instant exactly as given — no prefix, no encoding:

curl https://www.volunteerbadge.com/api/v1/checks/8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b \
  -H "Authorization: Bearer vb_live_..."

# → { "checkId": "...", "volunteerId": null, "status": "complete", "result": "clear",
#     "type": "direct", "creditsCharged": 1,
#     "offenderCount": 0, "errorMessage": null,
#     "completedAt": "...", "createdAt": "...",
#     "reportUrl": "...", "reportDownloadUrl": "..." }

type is direct for checks you order through /checks or /checks/instant, and fullfor a check produced by a volunteer's signed application. The response includes a legacy ssnTraceIncluded field that defaults to true in the database — do not branch on it. It does not reliably indicate whether an SSN trace actually ran: certified no-SSN checks (/checks/instant) have no trace even though the field may read true. SSN-bearing checks run a trace; check the report or dashboard SSN Trace card for the actual address-history result.

A check is only ever visible to the organization that ordered it — another org's checkId returns 404, not the record.

statusMeaning
processingStill running. Keep polling, or wait for the webhook.
completeFinished and clear — result is "clear".
pending_adjudicationPotential records found; a trained CRA reviewer is verifying them before release (typically within 3 business days). result stays null until that finishes.
adjudicatedThe terminal state for a records-found check. Review is finished: result is "clear" when the records were excluded as not this person / not reportable, or "consider"when records were released onto the report. Treat this as done — don't wait for complete, which a records check never reaches.
errorThe check could not be completed — see errorMessage. The credit is returned.
refundedThe check was refunded; no result will follow.
hits_foundTransitional, and rarely seen through the API — potential records were returned and are being routed for review.
pending_auto_releaseTransitional — a clear result queued for release.
pendingOrdered but not started yet.

reportDownloadUrl is a short-lived signed link, generated per request once the check is no longer processing — fetch it when you need it rather than storing it.

A records-found check sends TWO webhooks — don't stop at the first

check.complete fires when the search finishes. For a check with potential records that means it arrives carrying status: "pending_adjudication"— a human reviewer hasn't looked at it yet, and data.result is the string "null". It is not the answer.

check.adjudicated fires when that review completes, typically within 3 business days, carrying the final result — "clear" if the records were excluded as not your applicant, "consider" if records were released onto the report. Subscribe to it and you never need to poll. A clear check skips this entirely: check.complete is the whole story.

The adverse_action.* events are a separate workflow, not a check-resolution signal — they fire when your organization opens and works an adverse-action case in the dashboard.

Polling a sandbox check

Sandbox checks are simulated and never stored, so a chk_test_... id has no database row behind it. GET /api/v1/checks/{id} still answers for one — the outcome is encoded in the id, so it returns the same deterministic result the POST gave you, with "sandbox": true and creditsCharged: 0. That means you can build and test your polling loop end to end on a test key. A records-result sandbox check reports pending_adjudication with a null result and stays there, exactly as production does until a human finishes the review.

Certified instant check (no SSN)

Run an immediate check on firstName, middleName, lastName, dob, state with no SSN— for when you already hold the subject's signed FCRA disclosure and written authorization. Certify it per request with authorizationOnFile: true and a permissiblePurpose (one of volunteer_screening, employment_screening, youth_serving_organization). middleName is required unless you pass "requireMiddleName": false. SSNs are rejected on this endpoint.

curl -X POST https://www.volunteerbadge.com/api/v1/checks/instant \
  -H "Authorization: Bearer vb_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Jamie", "middleName": "Lee", "lastName": "Rivera",
    "dob": "1992-03-14", "state": "TX",
    "permissiblePurpose": "volunteer_screening",
    "authorizationOnFile": true
  }'

# → { "checkId": "...", "status": "...", "result": "...", "certified": { ... } }

Same screening-scope rule as POST /api/v1/checks: omit includeMisdemeanors / includeTrafficViolations to inherit Settings → Screening; send either flag to override that check only.

Identity verification required

Instant checks via the API — POST /api/v1/checks and POST /api/v1/checks/instant— require your organization's owner to have completed a free, one-time identity verification first. Until then these calls return 403 with code: ID_VERIFICATION_REQUIRED. The owner verifies once in Settings → Account (“Verify my identity”). This applies to every account, and consent-first POST /api/v1/applications invites are never affected.

Errors

StatusMeaning
400Missing or invalid field — see the error message.
401Missing or invalid API key.
402Insufficient credits — buy more in Billing.
403Forbidden — the CRA End-User Agreement must be re-accepted (Settings → API), or (code ID_VERIFICATION_REQUIRED) the org owner must verify their identity before instant checks.
404Resource not found.
429Rate limited — slow down and retry.

Webhooks

Instead of polling, register a URL in Settings → Webhooks and VolunteerBadge will POST to it when things happen.

Delivery

Each event is sent once to each subscribed URL. We wait 5 seconds. If the call times out or returns an error, we do not retry. A missed check can be read with GET /api/v1/checks/{id}.

Verify X-VolunteerBadge-Signature (HMAC-SHA256 of the raw body). timestamp is when the POST was built. There is no required time window and no extra replay check.

Deliveries do not come from fixed IP addresses. Do not allowlist them.

Available events:

EventFires when
check.completeThe search finishes. Clear checks arrive here done (status: complete); a check with potential records arrives as pending_adjudication with result set to the string "null" and is not finished.
check.adjudicatedA reviewer finishes adjudicating a records-found check (typically within 3 business days). Carries the final result — clear (records excluded) or consider (records released). Subscribe to this and you never have to poll.
check.errorA check fails to process.
application.submittedA volunteer submits their application.
volunteer.createdA new volunteer record is created. Matching or updating someone already on the roster does not fire it. It is off unless you subscribe to it.
adverse_action.case_openedA report with records is released and VolunteerBadge opens an adverse-action case (or a case is first created for that check).
adverse_action.notice_sentA pre-adverse or final adverse notice is delivered by email (<code>data.stage</code> is <code>pre</code> or <code>final</code>).
adverse_action.dispute_openedThe consumer submits a dispute through the ScreenForge Labs portal link from the pre-adverse notice.
adverse_action.dispute_resolvedScreenForge Labs CRA staff finish reviewing a consumer dispute.
adverse_action.completedThe final adverse notice is sent and the case is complete.

Example payloads

Every delivery is a JSON POST. The body is always the same envelope: event, data, and timestamp (when the POST was built, ISO-8601). Three headers come with it: Content-Type: application/json, X-VolunteerBadge-Event (the event name), and X-VolunteerBadge-Signature (HMAC-SHA256 of the raw body, hex, signed with that endpoint's secret). The samples below use one volunteer's ids so you can see how the events line up. volunteerId is JSON null on a direct or instant check, which has no roster row.

check.complete — search finished, clear. screeningMode is ssn_enriched, no_ssn_us, no_ssn_certified, or JSON null on older checks. reportUrl is a signed PDF link that expires after 7 days, or JSON null when the PDF was not stored. The URL below is shortened.

POST https://example.com/webhooks/volunteerbadge
Content-Type: application/json
X-VolunteerBadge-Event: check.complete
X-VolunteerBadge-Signature: 9f86d081884c7d65...

{
  "event": "check.complete",
  "data": {
    "checkId": "8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "status": "complete",
    "result": "clear",
    "classification": "clear",
    "screeningMode": "ssn_enriched",
    "completedAt": "2026-07-18T18:04:12.318Z",
    "reportUrl": "https://storage.example/check-reports/signed"
  },
  "timestamp": "2026-07-18T18:04:12.402Z"
}

check.complete — potential records, review still open. result is the four-character string "null". GET /api/v1/checks/{id} returns JSON null for that same stored value. Branch on status and wait for check.adjudicated.

{
  "event": "check.complete",
  "data": {
    "checkId": "8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "status": "pending_adjudication",
    "result": "null",
    "classification": "hits_found",
    "screeningMode": "ssn_enriched",
    "completedAt": "2026-07-18T18:06:44.110Z",
    "reportUrl": "https://storage.example/check-reports/signed"
  },
  "timestamp": "2026-07-18T18:06:44.188Z"
}

check.adjudicated — review finished. result is "consider" when records were released onto the report, or "clear" when they were excluded. offenderCount is how many records remain visible on the released report.

{
  "event": "check.adjudicated",
  "data": {
    "checkId": "8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "status": "adjudicated",
    "result": "consider",
    "offenderCount": 1,
    "completedAt": "2026-07-21T15:22:08.104Z"
  },
  "timestamp": "2026-07-21T15:22:08.221Z"
}

check.error — the check failed and the credit was returned. error is a short message, for example "SSN required to run background check".

{
  "event": "check.error",
  "data": {
    "checkId": "8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "error": "SSN required to run background check",
    "completedAt": "2026-07-18T18:04:40.512Z"
  },
  "timestamp": "2026-07-18T18:04:40.590Z"
}

application.submitted — a volunteer submitted an application. Spreadsheet imports do not fire this event. templateId is JSON null when the submission has no template.

{
  "event": "application.submitted",
  "data": {
    "applicationId": "a91c4e20-1d55-4b7a-8c33-6e0f2a9b4d18",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "templateId": "3f9a1c22-7b64-4e08-9d15-8a2c7e4b1f60",
    "submittedAt": "2026-07-18T17:41:03.220Z"
  },
  "timestamp": "2026-07-18T17:41:03.305Z"
}

volunteer.created — a new roster row was inserted (API, application, import, integration sync, mobile, badge invite, a school household adult, or a badge self-renewal). POST /api/v1/volunteers that returns created: false does not send this event. Blank email or phone arrives as JSON null. The payload never includes an SSN or application answers. Turn the event on in Settings → Webhooks; it is not part of the default subscription.

{
  "event": "volunteer.created",
  "data": {
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "firstName": "Jamie",
    "lastName": "Rivera",
    "email": "jamie@example.com",
    "phone": "555-014-8291",
    "status": "approved",
    "createdAt": "2026-07-18T17:00:00.000Z"
  },
  "timestamp": "2026-07-18T17:00:00.084Z"
}

adverse_action.case_opened — a report with records was released and a case was opened. subjectName is the person on the check.

{
  "event": "adverse_action.case_opened",
  "data": {
    "caseId": "c4e8b012-6a3d-4f91-9c27-1b5d8e0a7f42",
    "checkId": "8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "subjectName": "Jamie Rivera"
  },
  "timestamp": "2026-07-21T15:22:09.441Z"
}

adverse_action.notice_sent — a pre-adverse or final notice was emailed. stage is "pre" or "final". deliveryStatus on an emailed notice is "sent". Other values you can see on the notice record are pending, processing, delivered, failed, bounced, and mailed. The field itself can be JSON null. Sending the final notice also posts adverse_action.completed in the same action.

{
  "event": "adverse_action.notice_sent",
  "data": {
    "caseId": "c4e8b012-6a3d-4f91-9c27-1b5d8e0a7f42",
    "checkId": "8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "stage": "pre",
    "noticeId": "6d2a9f10-4c8e-4b31-a7e5-0f1c3b8d2a64",
    "deliveryStatus": "sent"
  },
  "timestamp": "2026-07-21T16:05:33.018Z"
}

adverse_action.dispute_opened and adverse_action.dispute_resolved share this shape. disputeId is JSON null when the dispute portal did not supply one.

{
  "event": "adverse_action.dispute_opened",
  "data": {
    "caseId": "c4e8b012-6a3d-4f91-9c27-1b5d8e0a7f42",
    "checkId": "8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "subjectName": "Jamie Rivera",
    "disputeId": "9b1e7c30-2a45-4d86-b0f1-5e8c4a7d3b21"
  },
  "timestamp": "2026-07-22T13:11:47.902Z"
}
{
  "event": "adverse_action.dispute_resolved",
  "data": {
    "caseId": "c4e8b012-6a3d-4f91-9c27-1b5d8e0a7f42",
    "checkId": "8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "subjectName": "Jamie Rivera",
    "disputeId": "9b1e7c30-2a45-4d86-b0f1-5e8c4a7d3b21"
  },
  "timestamp": "2026-07-24T19:02:15.667Z"
}

adverse_action.completed — the final notice was sent and the case is complete.

{
  "event": "adverse_action.completed",
  "data": {
    "caseId": "c4e8b012-6a3d-4f91-9c27-1b5d8e0a7f42",
    "checkId": "8f2b1c34-5d6e-4a70-9b81-2c3d4e5f6a7b",
    "volunteerId": "f883a1c2-7b64-4e08-9d15-8a2c7e4b1f60",
    "completedAt": "2026-07-28T14:30:00.000Z"
  },
  "timestamp": "2026-07-28T14:30:00.084Z"
}

Sandbox deliveries

A vb_test_ key posts check.complete, check.error, and volunteer.created to your real endpoint. Check bodies add subjectName, mode ("test"), and sandbox: true, and they omit screeningMode and reportUrl. A records sandbox check sends result: "consider" and classification: "records" while status is still pending_adjudication. A sandbox check.error has no error field — status and result are both "error".

{
  "event": "check.complete",
  "data": {
    "checkId": "chk_test_clear_4f8a1c9e2b7d60a13c5e",
    "volunteerId": null,
    "subjectName": "Jamie Rivera",
    "status": "complete",
    "result": "clear",
    "classification": "clear",
    "mode": "test",
    "sandbox": true,
    "completedAt": "2026-07-18T18:00:01.004Z"
  },
  "timestamp": "2026-07-18T18:00:01.090Z"
}
{
  "event": "check.complete",
  "data": {
    "checkId": "chk_test_records_91ab03de44c812f0aa77",
    "volunteerId": null,
    "subjectName": "Jamie Records",
    "status": "pending_adjudication",
    "result": "consider",
    "classification": "records",
    "mode": "test",
    "sandbox": true,
    "completedAt": "2026-07-18T18:00:02.114Z"
  },
  "timestamp": "2026-07-18T18:00:02.201Z"
}
{
  "event": "check.error",
  "data": {
    "checkId": "chk_test_error_c0ffee10ab23cd45ef67",
    "volunteerId": null,
    "subjectName": "Jamie Error",
    "status": "error",
    "result": "error",
    "classification": "error",
    "mode": "test",
    "sandbox": true,
    "completedAt": "2026-07-18T18:00:03.220Z"
  },
  "timestamp": "2026-07-18T18:00:03.301Z"
}

A sandbox POST /api/v1/volunteers posts volunteer.created with the same roster fields plus mode and sandbox. No roster row is written.

{
  "event": "volunteer.created",
  "data": {
    "volunteerId": "a91c4e20-1d55-4b7a-8c33-6e0f2a9b4d18",
    "firstName": "Jamie",
    "lastName": "Rivera",
    "email": "jamie@example.com",
    "phone": "555-014-8291",
    "status": "approved",
    "createdAt": "2026-07-18T17:00:00.000Z",
    "mode": "test",
    "sandbox": true
  },
  "timestamp": "2026-07-18T17:00:00.090Z"
}

Always verify the signature before trusting a payload. In Node:

import crypto from 'crypto';

function verify(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}

Prefer no-code?

Native one-click connectors are already available for several popular nonprofit platforms (CRM, donor, church-management, and volunteer-management systems) — see the Integrations overview. Until your platform is listed there, the API above — or the spreadsheet importer — covers any sync you need.
Didn't find what you needed? Email support@screenforgelabs.com or browse all articles.