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
/confirmationsList this org's confirmations for reconciliation
- Key
- org key
- Parameters
monthquery, requiredphonequery — E.164. Filter to one volunteer.statusquerycursorquerylimitquery- Responses
200OK401Unauthorized
/confirmationsConfirm 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
200Already existed (idempotent replay)201Created400BadRequest401Unauthorized403Forbidden429RateLimited
/confirmations/{id}- Key
- org key
- Parameters
undefinedundefined- Responses
200OK404NotFound
/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
undefinedundefined- Responses
200Retracted404NotFound409Already retracted
/confirmations/importNot built yetBulk import (CSV or JSON array)
Each row follows ConfirmationCreate. Rows are processed independently and idempotently. Returns per-row results.
- Key
- org key
- Responses
200Per-row results
/self-logsRecipient-logged hours at this org awaiting confirmation
Same data as the volunteer.self_logged webhook, for polling or reconciliation.
- Key
- org key
- Parameters
statusquerycursorquerylimitquery- Responses
200OK
/self-logs/{id}/declineDecline a recipient-logged entry
The recipient is notified. The entry is closed and will not appear on a letter.
- Key
- org key
- Parameters
undefinedundefined- Responses
200Declined404NotFound409Already resolved
Volunteers
/volunteers/linkCreate 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
200OK
/volunteers/{volunteer_id}Status for one volunteer attached to this org
- Key
- org key
- Parameters
volunteer_idpath, required- Responses
200OK
Organizations
/organizations/meNot built yet- Key
- org key
- Responses
200OK
/organizations/me/confirmersNot built yet- Key
- org key
- Responses
200OK
/organizations/me/confirmersNot built yetRegister a person who may confirm hours through this key
- Key
- org key
- Body
- Confirmer
- Responses
200OK
Platform
/platform/connectNot built yetStart 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
200OK
/platform/connect/exchangeNot built yet- Key
- platform credential
- Responses
200Org key issued400BadRequest
/platform/organizationsNot built yetOrgs connected through this platform
- Key
- platform credential
- Responses
200OK
Payer
/payer/recipientsNot built yetConsented recipients with current-month status
- Key
- payer key
- Parameters
monthqueryprogramquerycountyquerystatusquerycursorquerylimitquery- Responses
200OK
/payer/recordsNot built yetAttestation records for consented recipients in a month
- Key
- payer key
- Parameters
monthquery, requiredcursorquery- Responses
200OK
/payer/consent-requestsNot built yetAsk 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
202Accepted
Records
/records/{record_id}/verifyPublic verification (no auth)
- Parameters
record_idpath, required- Responses
200Verification result. Always 200 even for unknown IDs.
/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_idpath, required- Responses
200OK403Forbidden404NotFound
Webhooks
/webhooks- Key
- org key or payer key or platform credential
- Responses
200OK
/webhooks- Key
- org key or payer key or platform credential
- Responses
201Created. `secret` is shown once.
/webhooks/{id}- Key
- org key or payer key or platform credential
- Parameters
undefinedundefined- Responses
204Deleted
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