Verified Hours

Verified Hours API

Send confirmed volunteer hours, link volunteers, and read the signed records that come out the other end. Version 1.0.0.

Download the OpenAPI spec · How verification works

13 of 23 endpoints are built. The rest are specified and marked “Not built yet” below. Ask us before you depend on one.

Getting started

Base URL. https://verifiedhours.org/api/v1 Sandbox: https://sandbox.verifiedhours.org/api/v1

Auth. Authorization: Bearer vh_org_…. The key prefix sets what it can do: vh_org_ writes and reads one org’s hours, vh_plat_ mints org keys during a partner connect flow, vh_payer_ reads consented records for an agency, and vh_sb_ is the sandbox form of any of them. Keys are stored as hashes; we can’t show you one again after it’s issued.

Every request needs X-VH-Timestamp (RFC 3339, within 5 minutes) alongside the key.

Rate limits. 600 writes and 1200 reads per minute per key. Over that returns 429 with Retry-After.

Idempotency. POST /confirmations is keyed on your external_id. Sending the same one again returns 200 with the record you already created, never a duplicate or a conflict.

Corrections. Confirmed hours are never edited in place. A correction posts a new confirmation that supersedes the old one, and the volunteer’s letter is regenerated.

curl -X POST https://verifiedhours.org/api/v1/confirmations \
  -H "Authorization: Bearer vh_org_live_..." \
  -H "X-VH-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "shift_8891",
    "phone": "+12065550142",
    "date": "2026-09-14",
    "hours": 3.5,
    "activity": "Food sorting",
    "confirmer": { "name": "Ana Ruiz", "role": "Volunteer Coordinator" }
  }'

Errors

Every failure returns the same shape. We never return a stack trace.

{ "error": { "code": "validation_failed", "message": "Hours must be between 0.25 and 16.", "field": "hours" } }

Codes: validation_failedinvalid_keykey_revokedscope_deniedstale_timestamprate_limitednot_foundalready_retractedorg_not_verifiedorg_suspendedfuture_confirmed_athours_out_of_rangephone_invalidconfirmer_requiredconsent_required

Webhooks

Subscribe with POST /webhooks. Each delivery is signed: X-VH-Signature is an HMAC-SHA256 of the raw body with your subscriber secret — compare it before you trust the payload. Failed deliveries retry after 1 minute, 10 minutes, 1 hour, 6 hours and 24 hours, then land in the dead-letter list in the org dashboard.

Events: confirmation.createdconfirmation.retractedconsent.grantedconsent.revokedletter.generatedorg.connectedrecord.supersededvolunteer.linkedvolunteer.self_logged

Sandbox

Sandbox runs on its own database with vh_sb_ keys. It never sends a real text or email — messages land in an outbox you can read in the dashboard — so you can run a whole volunteer flow against it without touching anyone’s phone. Email us for keys while self-serve signup is being built.

Confirmations

GET/confirmations

List this org's confirmations for reconciliation

Key
org key
Parameters
month query, requiredphone query — E.164. Filter to one volunteer.status querycursor querylimit query
Responses
200 OK401 Unauthorized
POST/confirmations

Confirm volunteer hours for a shift

Idempotent on `(org, external_id)`. Repeating with the same `external_id` returns 200 and the existing confirmation. The volunteer is identified by phone. If no recipient profile exists yet, the confirmation is held as `pending_link` and attaches automatically when the volunteer links.

Key
org key
Responses
200 Already existed (idempotent replay)201 Created400 BadRequest401 Unauthorized403 Forbidden429 RateLimited
GET/confirmations/{id}
Key
org key
Parameters
undefined undefined
Responses
200 OK404 NotFound
DELETE/confirmations/{id}

Retract a confirmation (logged correction)

Not a hard delete. Marks the confirmation `retracted` with a reason and supersedes any issued record. To change hours, retract and POST a new confirmation with a new `external_id`.

Key
org key
Parameters
undefined undefined
Responses
200 Retracted404 NotFound409 Already retracted
POST/confirmations/importNot built yet

Bulk import (CSV or JSON array)

Each row follows ConfirmationCreate. Rows are processed independently and idempotently. Returns per-row results.

Key
org key
Responses
200 Per-row results
GET/self-logs

Recipient-logged hours at this org awaiting confirmation

Same data as the volunteer.self_logged webhook, for polling or reconciliation.

Key
org key
Parameters
status querycursor querylimit query
Responses
200 OK
POST/self-logs/{id}/decline

Decline a recipient-logged entry

The recipient is notified. The entry is closed and will not appear on a letter.

Key
org key
Parameters
undefined undefined
Responses
200 Declined404 NotFound409 Already resolved

Volunteers

POST/volunteers/link

Create or find a recipient by phone and get a link token

Returns a one-time URL the volunteer opens to claim their profile. Prefills phone and attaches the calling org. If the volunteer already has a profile attached to this org, returns `already_linked: true` and no URL. Does not send SMS unless `send_sms: true`.

Key
org key
Responses
200 OK
GET/volunteers/{volunteer_id}

Status for one volunteer attached to this org

Key
org key
Parameters
volunteer_id path, required
Responses
200 OK

Organizations

GET/organizations/meNot built yet
Key
org key
Responses
200 OK
GET/organizations/me/confirmersNot built yet
Key
org key
Responses
200 OK
POST/organizations/me/confirmersNot built yet

Register a person who may confirm hours through this key

Key
org key
Responses
200 OK

Platform

POST/platform/connectNot built yet

Start a partner connect flow for one nonprofit

Platform credential only. Returns a URL to send the coordinator to. The coordinator claims or creates the org (EIN check, logo). On completion Verified Hours redirects to `redirect_uri` with `?code=` and the platform exchanges it at /platform/connect/exchange for an org key. The coordinator never sees the key.

Key
platform credential
Responses
200 OK
POST/platform/connect/exchangeNot built yet
Key
platform credential
Responses
200 Org key issued400 BadRequest
GET/platform/organizationsNot built yet

Orgs connected through this platform

Key
platform credential
Responses
200 OK

Payer

GET/payer/recipientsNot built yet

Consented recipients with current-month status

Key
payer key
Parameters
month queryprogram querycounty querystatus querycursor querylimit query
Responses
200 OK
GET/payer/recordsNot built yet

Attestation records for consented recipients in a month

Key
payer key
Parameters
month query, requiredcursor query
Responses
200 OK
POST/payer/consent-requestsNot built yet

Ask a list of recipients (by phone) to share records with this payer

Each recipient gets an SMS. Consent is per payer and revocable. Response reports how many matched an existing profile.

Key
payer key
Responses
202 Accepted

Records

GET/records/{record_id}/verify

Public verification (no auth)

Parameters
record_id path, required
Responses
200 Verification result. Always 200 even for unknown IDs.
GET/records/{record_id}

Full signed record (org or payer scope)

Org keys may read records that include their confirmations. Payer keys may read records for consented recipients.

Key
org key or payer key
Parameters
record_id path, required
Responses
200 OK403 Forbidden404 NotFound

Webhooks

GET/webhooks
Key
org key or payer key or platform credential
Responses
200 OK
POST/webhooks
Key
org key or payer key or platform credential
Responses
201 Created. `secret` is shown once.
DELETE/webhooks/{id}
Key
org key or payer key or platform credential
Parameters
undefined undefined
Responses
204 Deleted

Objects

Confirmer

id
string
namerequired
string
rolerequired
string
email
string
external_id
string

ConfirmationCreate

external_idrequired
string
phonerequired
string
display_name
string
daterequired
string
start_time
string
end_time
string
hoursrequired
number
activity
string
confirmerrequired
Confirmer
confirmed_at
string
self_log_id
string

Confirmation

idrequired
string
external_idrequired
string
volunteer_id
string
phone_last4
string
statusrequired
ConfirmationStatus
daterequired
string
start_time
string or null
end_time
string or null
hoursrequired
number
activity
string or null
confirmer
Confirmer
source_platformrequired
SourcePlatform
confirmed_atrequired
string
created_atrequired
string
retracted_at
string or null
retraction_reason
object
record_ids
string[]

Organization

id
string
legal_name
string
ein_last4
string
bmf_verified
boolean
city
string
state
string
status
active | review | suspended
default_activity
string or null
allow_self_log
boolean

AttestationRecord

idrequired
string
recipient_display_name
string
phone_last4
string
payer_external_id
string or null
monthrequired
string
programrequired
Program[]
staterequired
string
total_hoursrequired
number
statusrequired
valid | superseded | withdrawn
supersedes_id
string or null
superseded_by
string or null
organizations
object[]
shifts
object[]
statement
string
canonical_jsonrequired
string
signature_b64required
string
key_idrequired
string
generated_atrequired
string
verify_url
string
letter_pdf_url
string or null

VerificationResult

record_idrequired
string
statusrequired
valid | superseded | withdrawn | not_found
month
string
total_hours
number
organizations
object[]
generated_at
string
corrections_since_generation
integer
current_record_id
string or null
signature_b64
string
key_id
string
canonical_json
string

PayerRecipient

recipient_ref
string
payer_external_id
string or null
display_name
string
phone_last4
string
programs
Program[]
state
string
county
string or null
consented_at
string
month
string
confirmed_hours
number
pending_hours
number
pace_status
PaceStatus
organizations
string[]
last_confirmation_at
string or null
current_record_id
string or null

Webhook

id
string
url
string
events
WebhookEvent[]
secret
string
status
active | failing | disabled
created_at
string

WebhookPayload

id
string
event
WebhookEvent
occurred_at
string
data
object

SelfLogEvent

self_log_idrequired
string
volunteer_idrequired
string
phone_last4required
string
display_namerequired
string
daterequired
string
hoursrequired
number
activity
string or null
logged_at
string

ErrorBody

coderequired
validation_failed | invalid_key | key_revoked | scope_denied | stale_timestamp | rate_limited | not_found | already_retracted | org_not_verified | org_suspended | future_confirmed_at | hours_out_of_range | phone_invalid | confirmer_required | consent_required
messagerequired
string
field
string

Error

errorrequired
ErrorBody