API keys & webhooks
The Developer area holds the credentials and callbacks that let external systems talk to your organization: API keys for the /api/v1 REST API and webhooks that push signed events to your endpoints. Managing either one requires an organization owner or admin role.
API keys
API keys grant programmatic access to the organization's /api/v1 REST API. Each key is scoped to the organization that owns it.
cr_live_prefix — the full token has the formcr_live_<secret>. The server stores only a SHA-256 hash and a short 12-character prefix; a database leak never returns a working key.- Shown only once — the plaintext token exists only at creation time and cannot be recovered afterward. Copy it right away.
- Optional expiration — a key can have an
expiresAtor never expire; an expired key authenticates as invalid. - Revocation (soft) — revoking marks the key as revoked but keeps the row for auditing; a revoked key stops authenticating immediately.
- Last used — each successful authentication stamps
lastUsedAt, useful for spotting idle keys.
The API uses the token as a Bearer; it resolves the organization that owns the key and operates within it.
Keys are managed at /dashboard/settings/developer/api-keys.
Webhooks
Webhooks deliver signed events to your HTTPS endpoints, with a delivery history (the ‘calls’). Endpoints are managed at /dashboard/settings/developer/webhooks.
Available events
| Event | Trigger |
|---|---|
enrollment.created | A new enrollment is created |
content.completed | A learner completes the content |
purchase.created | A purchase is recorded |
purchase.refunded | A purchase is refunded |
enrollment.requested | A learner requests enrollment, or a pay-first checkout creates a pending request |
enrollment.approved | A pending enrollment request is approved |
enrollment.rejected | A pending enrollment request is rejected (a pay-first buyer is refunded) |
enrollment.expired | A paid request passes its 30-day decision deadline |
learner.unenrolled | A learner is removed from a content |
content.created / content.updated / content.deleted | Content lifecycle or public/commercial settings change |
content.published | A content is published |
review.created / review.updated | A learner posts or updates a review |
cohort.created / .updated / .deleted | A cohort's lifecycle changes |
cohort.joined | A learner voluntarily joins a cohort |
cohort.member.moved / .removed | Staff moves or removes a learner |
discussion.post.created | A member posts a question or reply in a cohort |
discussion.post.deleted / .resolved / .reopened | A question or reply is deleted, resolved, or reopened |
offer.created | An offer is added to a course |
offer.updated / offer.primary_changed | Offer terms change or another offer becomes primary |
offer.archived | An offer is archived (stops selling) |
purchase.installment.paid | A monthly installment on a payment plan is paid |
purchase.installment.failed / .recovered / .canceled | The plan becomes past due, recovers, or is canceled |
campaign.redeemed | A campaign code is consumed on a completed purchase |
How delivery works
- Signing secret — each webhook receives a
whsec_…secret, generated on the server and shown only once at creation. - Envelope and idempotency — every body includes
id,event,version,createdAt,organizationId, anddata. The sameidand body are preserved across attempts; deduplicate byid. - HMAC signature — the JSON
POSTincludesx-cursare-event,x-cursare-event-id,x-cursare-event-version,x-cursare-timestamp, andx-cursare-signature(HMAC-SHA256 of the body with the secret). Validate it before trusting the payload. - Timeout and resilience — each attempt has a 5-second limit. Events live in a transactional outbox and unsuccessful deliveries retry with exponential backoff, up to eight attempts. Delivery is at least once, so receivers must be idempotent.
- Delivery history — each attempt records the response status, body (truncated), error, success, and duration, queryable as the endpoint’s history of ‘calls’.
- Manual test (ping) — you can send a test event (
webhook.test) to an endpoint and see the attempt in the history. - Enable/disable and delete — inactive endpoints don’t receive the fan-out; only those that are active and subscribed to the event are triggered.
A webhook can be assigned to a team within the organization, via teamId, to group endpoints by owner (personal member or team) in the listing.
Local outbox worker
PostgreSQL is the event source of truth. The local adapter tries to process just after the HTTP response, while GET /api/cron/process-events runs an hourly contingency reconciliation using the same CRON_SECRET as the other cron jobs. Moving to Inngest or Trigger.dev only requires another task adapter; producers and handlers keep using serializable IDs and never import the provider SDK.
REST API
The /api/v1 API manages almost everything the dashboard does. The exact request and response schemas are generated from the same contracts that run the API — see the interactive API reference (in production on its own host, docs.cursare.com/api). This section is the map: how the API behaves and which resources it covers.
Conventions
Every call shares the same rules:
- Authentication —
Authorization: Bearer cr_live_…. A key resolves to one organization and acts with org-admin authority scoped to it; reaching another organization's data is impossible. - Rate limits — 240 reads/min and 60 writes/min per key. Every response carries
X-RateLimit-Limit,-Remaining, and-Reset; a429addsRetry-After. - Pagination — list endpoints take
pageandpageSize(capped). A page past the end returns an empty array, so you can looppage++until it's empty. - Money — amounts are integer minor units (e.g. cents) paired with a
currency. Amounts in different currencies are never summed into one number. - Errors —
400validation,401bad key,403not permitted,404not found (a resource in another org reads as not-found),429rate-limited. The body is{ "error": "…" }. - Request bodies are JSON, capped at 4 MB.
- Kind and authoring profile — content payloads carry
kind(courseormaterial);slugis null for materials, which have no public page or offers. The stored kind also selects the document profile: a course containscoursePresentation, a material-onlycourseCurriculum, and course recommendations incourseRelated; a material contains the full learning blocks.GET /document-schema?kind=…documents each profile; document mutations ignore any caller-supplied kind. Creating a course also creates its primary Standard offer;promoteanddemotechange the kind. - Offer, enrollment, and purchase —
offer.isPrimaryis the sole source of the primary offer; there is nocontent.primaryOfferId.learner.offerId,learnerRequest.offerId, andpurchase.offerIdare required. Paid learners and pay-first requests also carry the exactpurchaseId; free/comped rows keep it null.learner.statusalone authorizes access, and a payment event may change it only while its purchase still backs that learner. Uniqueness remains(userId, contentId): several offers or historical purchases never create parallel enrollments. - Cohort on the enrollment — every
cohortbelongs to exactly one offer.learner.cohortIdandlearnerRequest.cohortId, when present, must target a cohort of that same offer/course pair;learner.cohortRolestoresmemberorstar. There is no parallel membership table.offer.deliveryModeisself_pacedorcohort, andoffer.discussionEnabledcontrols discussion for that offer's cohorts.
What you can manage
| Resource | Endpoints (prefix /api/v1) | What you can do |
|---|---|---|
| Organization | GET,PATCH /organization · GET /members | Read/update the organization profile and list staff with their roles |
| Custom domain | GET,PUT,DELETE /organization/custom-domain · POST …/verify | Connect a domain, fetch the DNS records, run the ownership check, disconnect |
| Organization configuration | GET /organization/shape | Read the organization structure used by automation |
| Lookup & governance | GET /resolve · GET /audit-events · GET /api-keys · DELETE /api-keys/{id} | Resolve human names/slugs/emails to IDs, inspect the audit trail, list and revoke credentials |
| Contents | GET,POST /contents · GET,PATCH,DELETE /contents/{id} · POST /contents/{id}/publish · POST /contents/{id}/duplicate · POST /contents/{id}/promote · POST /contents/{id}/demote | List (paginated + filters), create, edit pricing/theme/access/format/ownership, publish, duplicate the packaging, and change kind (promote a material to a course, turn a course into a material) |
| Document | GET /document-schema · GET,PUT /contents/{id}/document | Inspect the authoring format, read the draft/published document, or replace the draft |
| Offers | GET /offers · GET,POST /contents/{id}/offers · GET,PATCH /offers/{id} · POST /offers/{id}/duplicate · …/set-primary · …/archive | A course's sellable packages — price, format, enrollment terms; create, update, make primary, duplicate, archive |
| Learners | GET /learners · GET,POST /contents/{id}/learners · PATCH,DELETE …/{userId} · POST …/{userId}/reset · POST …/learners/reset · GET …/{userId}/mastery | List/search, enroll, update metadata, unenroll, inspect mastery, and reset progress individually or in bulk |
| Enrollment requests | GET /contents/{id}/enrollment-requests · GET /enrollment-requests/summary · POST /enrollment-requests/{id}/approve · …/reject | Read per-course queues or the org-wide summary, approve, or reject with automatic pay-first refund |
| Cohorts | GET,POST /contents/{id}/cohorts · PATCH,DELETE /cohorts/{id} · members sub-routes | Create/manage an offer's cohorts (dates, seats, one tutor), assign/move/remove learners through enrollment, set the star badge |
| Cohort discussion | GET,POST /cohorts/{id}/discussion · POST /discussion/{postId}/resolve · …/pin · DELETE /discussion/{postId} | Read a cohort's Q&A threads (with replies), post/reply as staff or the tutor, and moderate (resolve, pin, delete) |
| Intake forms | GET,POST /intake-forms · GET,PATCH,DELETE /intake-forms/{id} | Manage reusable enrollment-question sets |
| Campaigns | GET,POST /campaigns · PATCH,DELETE /campaigns/{id} | Discount codes — per-course or global |
| Teams | GET,POST /teams · GET,PATCH,DELETE /teams/{id} · member and member-role sub-routes | Teams, memberships, and each member's content-domain roles |
| Sales & purchases | GET /sales · GET /purchases · GET /purchases/{id} · GET /contents/{id}/purchases · POST /purchases/{id}/refund | The order ledger (filter by content, buyer, status, date) and refunds |
| Payouts | GET /payouts | Actual transfers grouped by currency |
| Affiliates | GET,PATCH /affiliate-program · GET /affiliates · approve/block/commission/settle sub-routes | Configure the native affiliate program, moderate affiliates, override commission, and settle balances |
| Insights | GET /insights · GET /insights/series · GET /contents/{id}/insights · …/sales | Headline numbers, time series, and per-content academic + sales figures |
| Reviews | GET /contents/{id}/reviews | Read ratings and the review feed (read-only) |
| Integrations | GET /integrations · Google/WhatsApp/video/payment sub-routes | Inspect the four typed capabilities, run Google OAuth and managed GTM review, update WhatsApp, create direct video uploads, or disconnect providers |
| MCP connections | GET /mcp/connections · DELETE /mcp/connections/{clientId}/{userId} | List and revoke organization-bound OAuth assistant grants without exposing tokens |
| Webhooks | GET,POST /webhooks · PATCH,DELETE /webhooks/{id} · …/deliveries · …/test | Register and manage the endpoints described above, inspect the delivery log, send a test |
Refunding expired paid requests on Vercel
Pay-first requests have a 30-calendar-day decision window. The deadline is neither a column nor a per-offer setting: it is always derived from learnerRequest.createdAt + 30 days. Free requests have a null purchaseId and never enter this process.
| Operational item | Value |
|---|---|
| Configuration | apps/web/vercel.json — the Vercel project uses apps/web as its Root Directory |
| Schedule | 0 5 * * * — one daily run at 05:00 UTC |
| Endpoint | GET /api/cron/expire-enrollment-requests |
| Authentication | Authorization: Bearer <CRON_SECRET> sent by Vercel |
| Secret | CRON_SECRET is required in Production, random, and at least 16 characters long |
| Environments | Vercel schedules the cron only on Production deployments; Preview and local development do not trigger it automatically |
| Response | { expired, scanned, refunded, failed } for inspection in the function logs |
GET /contents/{id}/enrollment-requests exposes expiresAt (null for a free request) and refundPending. When refundPending is true, API clients must not offer manual actions; the approval and rejection endpoints return 400 to preserve the durable retry queue.
Ordering and failure recovery
approveRequestblocks a paid request as soon as it reaches 30 days, independently of the cron job.- The cron marks overdue paid requests
expired, then selects everyexpiredorrejectedrequest whose purchase has norefundedAtand is not canceled. - It calls
PaymentProvider.refundfirst, usingpurchase.idas the idempotency key. - Only after provider confirmation does it call
markRefunded: it recordsrefundedAt, removes the request from work without deleting its history, adjusts the coupon, and publishespurchase.refunded. - The buyer email is best-effort and happens after the money operation; an email failure never rolls back the refund.
- If the provider or database fails before completion, the request remains visible as Refund pending and is retried the next day with the same key.
MCP server
Cursare ships an MCP server (Model Context Protocol) so AI clients — Codex, ChatGPT, Claude, Cursor, and any MCP-compatible agent — can operate your organization directly. The endpoint is https://mcp.cursare.com. To connect Codex with a browser login:
codex mcp add cursare --url https://mcp.cursare.com
codex mcp login cursare
Two auth modes are available: OAuth (the server acts as the signed-in user, with their real team roles — editor, finance, cohort, learners) or an org API key (cr_live_… from Settings → Developer → API keys, org-admin authority, headless). The full guide — every tool, connection steps per client, authoring workflow, and protocol details — lives at MCP server.
Best practices
- Record who created each key or endpoint, which environment it uses, and how to revoke the credential.
- Treat every key and every webhook secret as sensitive: shown once, stored as a hash/cipher, never in the frontend or screenshots.
- Test in the appropriate environment whenever you rotate a key or point a webhook at a new endpoint.