API and webhooks
Version 1. Last updated October 11, 2026. Included with Pro and School.
Connect ChoicePaid to your booking site, CRM, accounting or automation tool. Create students and sessions from wherever they start, read invoices and their status, and get a signed webhook the moment something is paid. Manage keys and webhooks in Account, then Developers.
Authentication
Send your key as a bearer token on every request. Keys start with cp_live_, are shown once when created, and are stored only as a one-way hash. A read-only key can call every GET; changes need a read and write key. Keep keys on your server; never put one in a web page or an app people download.
curl https://www.choicepaid.com/api/v1/me \
-H "Authorization: Bearer cp_live_your_key"Errors and limits
Responses are JSON. Errors look like {"error":{"code":"invalid","message":"…"}} with a matching HTTP status: 400 invalid, 401 unauthorized, 403 plan, scope or limit, 404 not_found, 409 transition or checks_failing, 429 rate_limited. Each key can make 120 requests a minute; responses include X-RateLimit-Remaining, and a 429 includes Retry-After. Money is always in cents; dates are YYYY-MM-DD.
Pagination
List endpoints return {"data":[…],"next_cursor":"…"}, newest first. Pass limit (1 to 100, default 50) and send next_cursor back as cursor for the next page. A null next_cursor means you have everything.
Endpoints
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/me | The workspace the key belongs to: business name, plan, key scope. |
| GET | /api/v1/students | Students, newest first. include_archived=true to include archived. |
| GET | /api/v1/students/:id | One student. |
| POST | /api/v1/students | Add a student: name, program (slug), optional parent, grade, family_email. Write key. |
| GET | /api/v1/sessions | Sessions. Filters: student_id, from, to (YYYY-MM-DD), status (scheduled, delivered, cancelled). |
| POST | /api/v1/sessions | Log or schedule a session: student_id, date, description, rate_cents; optional quantity, start_time, minutes, status, note, share_note, payer. Write key. |
| GET | /api/v1/invoices | Invoices, receipts and service records. Filters: status, student_id, from, to (issue date). |
| GET | /api/v1/invoices/:id | One invoice with its lines, student name and status history. |
| PATCH | /api/v1/invoices/:id | Change status (submitted, approved, paid, rejected, void, with on, reason, rejection_code) or set reference (payment platform order number). Write key. |
Status changes follow the same rules as the app: draft to submitted, paid or void; submitted to approved, paid, rejected or void; approved to paid or rejected; rejected back to submitted or void. A draft with a failing program check can't be marked submitted until it's fixed. Voiding releases its sessions so they can be billed again.
Examples
Log a delivered tutoring hour:
curl -X POST https://www.choicepaid.com/api/v1/sessions \
-H "Authorization: Bearer cp_live_your_key" \
-H "Content-Type: application/json" \
-d '{"student_id":"5b0c…","date":"2026-10-09","start_time":"15:30",
"minutes":60,"description":"Math tutoring, 1:1","rate_cents":6500}'Mark an invoice paid:
curl -X PATCH https://www.choicepaid.com/api/v1/invoices/9e1f… \
-H "Authorization: Bearer cp_live_your_key" \
-H "Content-Type: application/json" \
-d '{"status":"paid","on":"2026-10-11"}'Webhooks
Add an https endpoint and pick events. Each event is a POST with a JSON body like this, plus the headers ChoicePaid-Event, ChoicePaid-Delivery (unique per delivery; use it to ignore duplicates) and ChoicePaid-Signature.
{
"id": "d4c1…",
"type": "invoice.paid",
"created": 1791763200,
"data": {
"id": "9e1f…", "number": "INV-0042", "student_id": "5b0c…",
"program": "arizona-empowerment-scholarship", "kind": "invoice",
"payer": "program", "status": "paid", "total_cents": 26000,
"issued_on": "2026-10-01", "due_on": "2026-10-31",
"submitted_on": "2026-10-02", "paid_on": "2026-10-11",
"reference": null, "checks_failing": 0
}
}invoice.created: a draft invoice, receipt or service record was created.invoice.status_changed: an invoice moved to submitted, approved, paid, rejected or void.invoice.paid: an invoice was marked paid (also sent as invoice.status_changed).session.created: a session was logged or scheduled.student.created: a student was added.family.connected: a family connected their free family account to a student.family.payment_reported: a family said they requested payment or paid their share.ping: sent by the "Send test" button.
Reply with any 2xx status within 8 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours (7 tries in all). Deliveries can arrive more than once or out of order, so key on ChoicePaid-Delivery and compare created. Payloads never include names: call the API if you need them. Redirects aren't followed, and endpoints on private or internal networks are refused.
Verifying signatures
The signature header is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of <t>.<raw body> using your endpoint's signing secret (starts whsec_). Check it against the raw body before parsing, compare in constant time, and reject timestamps more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyChoicePaid(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Program slugs
Students take a program slug:
| Program | Slug |
|---|---|
| Texas TEFA | texas-tefa |
| Utah UFA | utah-fits-all |
| Georgia Promise Scholarship | georgia-promise |
| Louisiana GATOR | louisiana-gator |
| Wyoming ESA | wyoming-esa |
| Missouri MOScholars | missouri-moscholars |
| Iowa Students First | iowa-students-first |
| Arizona ESA | arizona-empowerment-scholarship |
| Florida Step Up For Students | florida-step-up |
| Alabama CHOOSE Act | alabama-choose-act |
| West Virginia Hope Scholarship | west-virginia-hope |
| Indiana INESA | indiana-esa |
| Tennessee EFS and ESA | tennessee-efs-esa |
| Arkansas EFA | arkansas-efa |
| North Carolina ESA+ | north-carolina-esa-plus |
| South Carolina ESTF | south-carolina-estf |
| New Hampshire NH EFA | new-hampshire-efa |
| Mississippi ESA | mississippi-esa |
| Montana ESA | montana-esa |
Questions
Which plans include the API?
Pro and School. API keys and webhooks are managed by the workspace owner in Account, then Developers.
Do webhooks include student names?
No. Webhook payloads carry ids, amounts, dates and statuses only. If your system needs a name, fetch the record through the API with your key, so names only travel over a request you authenticated.
Can the API submit invoices to ClassWallet, Odyssey or a state program?
No. ChoicePaid never submits anything to a program or payment platform. The API changes the status you track in ChoicePaid, the same as clicking the buttons in the app, and drafts must pass their program checks before they can be marked submitted.
What happens if my webhook endpoint is down?
Each delivery is tried up to 7 times over about a day and a half. After 20 failed deliveries in a row the webhook is switched off and the owner gets an inbox notice. You can retry any delivery from the log.
Is there a sandbox?
Create a read-only key and a sample student in your workspace to try requests safely. Sample students and their invoices are left out of API results, so add a real test student if you want to see one in the API.
Something missing? Tell us what you're connecting.