Payments & payouts
This page follows the money after checkout: the provider abstraction that runs every charge, how funds settle to your connected account, how installments and refunds behave, and the guarantee that no one is ever charged without getting access. For setting a price, running checkout, or issuing coupons, see Pricing & checkout.
Every monetary value is stored and transmitted in the currency's smallest unit (cents). A price of 9990 in BRL means R$99.90.
The pluggable payment provider
All commerce goes through a provider-agnostic contract (PaymentProvider) — never through a concrete SDK in business code. Stripe and signed external checkout handoffs are adapters selected by the organization's integration row.
The contract exposes only what's essential to sell a piece of content:
| Member | Role |
|---|---|
connected | Whether proceeds actually reach the organization; gates every priced checkout |
createCheckout | Starts a one-time checkout; returns the redirect URL |
createInstallmentPlan | Starts an installment plan (N recurring charges); returns the URL |
finalizeInstallmentPlan | Caps the subscription at exactly N charges (a backstop on the return) |
verifyCheckout | Re-reads the session and returns the facts only when paid; null otherwise |
refund | Fully refunds the charges behind a purchase (idempotent by key) |
resolvePaymentProvider(orgId) reads the organization's integration row (kind payments). Selling requires one valid payment integration; until it exists, priced checkouts are refused (PaymentsNotConnectedError) and only free content works.
Before any session is created, server-side checkout eligibility blocks active enrollment, pending approval, and a past_due plan. The live-purchase partial constraint and idempotent fulfillment remain the second line of defense for two sessions that pass preflight concurrently; only that race may end in an automatic duplicate refund.
External checkout protocol
An external integration stores a checkout URL and a one-time HMAC secret. Cursiva redirects the browser to that URL with purchase_request_id, amount, currency, title, return_url, cancel_url, webhook_url, timestamp, signature_payload, and signature query parameters. The external system should verify HMAC_SHA256(secret, timestamp + "." + signature_payload) before showing payment details.
Payment confirmation is a JSON POST to the supplied webhook_url. Send the Unix timestamp in x-cursiva-timestamp and the lowercase hex HMAC in x-cursiva-signature:
HMAC_SHA256(secret, timestamp + "." + raw_request_body)
The timestamp must be within five minutes. Sign the exact UTF-8 bytes sent on the wire; reformatting JSON after signing invalidates the signature.
{
"type": "purchase.paid",
"purchaseRequestId": "request-id-from-the-handoff",
"paymentId": "provider-order-id",
"amount": 9990,
"currency": "BRL"
}
purchase.failed closes an unpaid request and purchase.refunded marks the matching paymentId refunded and revokes access. Automatic refunds and installments are not available for the generic external adapter: perform those operations in the external system and send the corresponding signed event.
Stripe Connect: separate charges & transfers and the platform fee
When an organization connects its own Stripe account, sales start settling to it, and the platform retains its fee: a percentage of every payment (PLATFORM_FEE_BPS, default 5%) plus a fixed amount once per sale (PLATFORM_FEE_FIXED, default R$2.00), both overridable per organization and snapshotted onto the purchase at checkout creation, so a renegotiation never rewrites past sales. It isn't a Stripe application fee — it's simply what stays on the platform balance when the net is transferred. To connect the payout account, see Integrations.
The model is separate charges & transfers: checkout always runs on the platform account — so verification, refunds, and webhooks stay platform-scoped, with no change — and the charge carries no transfer_data. After each payment settles, Cursiva creates an explicit transfer of the net share (gross minus the fee) to the connected account, anchored to that charge via source_transaction and recorded in the purchase_transfer ledger (idempotent per charge).
- Express account. The organization connects an Express account (onboarding and dashboard hosted by Stripe), with a default country of
BRand both thetransfersandcard_paymentscapabilities requested — the account only ever receives transfers, but Stripe refuses a BR account that requeststransfersalone. - No per-organization secret. Only the
acct_…id is kept in theintegrationrow (kindpayments, providerstripe); there's no per-organization secret key — charges use the platform key plus the account id. That's why the id isn't sensitive and its status can be read by any member. - Refund. The refund runs in full on the platform account; the organization's payout is clawed back by reversing the transfer recorded in the
purchase_transferledger (reverseTransferForCharge), a no-op when the payment was never settled — so a full refund leaves no one holding the buyer's money. The inlinereverse_transfer/refund_application_feeflags survive only for legacy destination charges from before this model. - Connected installments. The subscription carries no
transfer_dataeither: each paid invoice settles through its own explicit transfer on theinvoice.paidwebhook, net of the percentage taken from every installment plus the fixed fee, which lands whole on the first one.
Installments and payment methods
An installment plan is a Stripe subscription (subscription mode) split into N equal monthly charges — not the card network's native installment feature. Each installment is a recurring charge; the plan is closed right after the last one so Stripe never opens invoice N+1.
- Minimum installments: a plan needs at least 2 monthly charges. The
priceyou provide is the total, divided here. - Amount per installment:
ceil(total price / N)— rounded up, so N installments never fall below the total price. Every installment is identical, which means the plan can collect up to N−1 cents more than the listed price. - Recorded revenue: the first session's
amount_totalis only the first installment; Cursiva records the plan's full amount (kept in metadata asfullPrice) inpurchase.amount, so revenue totals count the entire sale and not a single installment. - N-charge cap: after payment, the plan is capped at exactly N installments via
cancel_at, scheduled one day after the last installment. This closeout is idempotent and runs reliably through the webhook.
Payment methods differ by kind. A one-time checkout uses Stripe's automatic payment methods, so in Brazil the buyer can pay by card, pix, or boleto without extra setup. Installment checkout, being a recurring subscription, is card only (payment_method_types: ["card"]).
Freeze, thaw, and cancellation
The learner's access follows the plan's state, driven by Stripe's invoice webhook (which has no actor — it's server-only):
past_due— a charge failed: freezes access (the learner becomespast_dueand can't get in).active— the next invoice was paid: thaws access. A purchase that has already been refunded is not resurrected.canceled— the plan ended: revokes access.
A delinquent installment plan therefore grants no access: only an unrefunded one-time purchase, or a plan that's current, unlocks the content.
Reconciling a stuck past_due
If the reactivation webhook (invoice.paid) is lost, the learner can get stuck in past_due even though the plan is already current in Stripe. To close that trap, Cursiva re-reads the subscription directly from Stripe as the source of truth and maps the status to the internal vocabulary:
| Stripe status | Purchase state |
|---|---|
active, trialing | active (current) |
past_due, unpaid, incomplete | past_due (in arrears) |
canceled, incomplete_expired | canceled (ended) |
When the learner is unenrolled, the installment plan is canceled without a refund — access ends now, so future installments also have to stop. The subscription lives on the platform account, and the cancellation is idempotent.
Refunds and the money-before-access guarantee
A refund is recorded by marking refundedAt on the purchase. The operation is idempotent and revokes access.
- Once only. A purchase that's already refunded is rejected — each purchase refunds exactly once, protected by the transition of
refundedAt. - Effects. Marking as refunded sets
refundedAtand, only if this exact purchase still backs the enrollment, moves the learner torefunded(the row remains, with progress and history, but every access predicate excludes it). The pay-first request also stays as history;refundedAtonly removes it from refund work. The operation still decrements the coupon redemption and firespurchase.refunded. - Provider idempotency. The refund's idempotency key is the purchase id, so retries and duplicate sends collapse into a single refund.
- Who can refund. Refunding requires pricing rights (finance/admin on the team that owns the content, or the organization's owner/admin). If the content has been deleted since the sale, the fallback is the organization's owner/admin — never an ordinary member.
Automatic refund when enrollment fails
The golden rule is never take money without delivering access. When a paid checkout closes out, if enrollment fails after payment — for example, on a time-based course whose cohort filled up or closed during the window — Cursiva refunds the charge automatically and marks the purchase as refunded, returning an ‘unavailable’ result. The same automatic refund fires when a pay-first request is rejected or reaches 30 calendar days without a decision.
Pay-first request left undecided for 30 days
The deadline belongs to the paid request, not to the enrollment or course: expiresAt is derived from learnerRequest.createdAt + 30 days. Free requests have no deadline.
- Before the deadline: the team can approve or reject normally.
- At the expiry instant: the domain rejects approval even if the job has not run yet; the dashboard shows Refund pending and removes manual actions.
- On the next daily run: a Vercel Cron Function authenticated by
CRON_SECRETselects the expired request. - Provider first: Cursiva requests the refund using the purchase id as its idempotency key.
- Database second: only after provider confirmation does it set
refundedAt; this removes the request from refund work without deleting its history, decrements the coupon redemption, and firespurchase.refunded. - On failure:
refundedAtstays null; the terminal request remains in the queue and enters the next day's run again.
Refunding an installment plan
Refunding an installment purchase first cancels the subscription (to stop future charges) and then refunds each paid invoice, returning to the buyer exactly what they've paid so far. An already-canceled subscription doesn't block the refunds.
Buyer tax identity for invoicing
At checkout Cursiva collects the buyer's tax identity so you can issue the invoice your country requires. This is international: the checkout shows the field that fits the buyer's country — CPF/CNPJ in Brazil, VAT in the EU, EIN in the US, and so on. Entering it is always optional — a missing tax id never blocks the sale, so capture adds no checkout friction. Need guaranteed data? Turn on Require a tax ID in Settings → Tax & invoicing.
Each sale records four fields, all optional (the buyer may skip them):
| Field | What it is |
|---|---|
| Legal name | The name the buyer entered for the invoice |
| Tax id | The number itself (a CPF, a VAT number, …) |
| Tax id type | The country-specific type — br_cpf, br_cnpj, eu_vat, us_ein, … |
| Country | The buyer's country, when collected |
You read this data in three places:
| Where | Use |
|---|---|
| Insights → Financial → Sales (CSV) | One row per sale with the tax id — hand it to your accountant or an invoicing tool |
Purchases API (GET /api/v1/purchases) | Pull it programmatically — e.g. an automation that emits the invoice |
purchase.created webhook | React in real time as each sale is recorded |
Financial state lifecycle
Two states move together but have different authority: the purchase state is financial history; learner.status is the single access source. A paid learner/request stores the exact current purchaseId, and only events from that purchase may change the learner. Every access predicate considers only active learners; no access query needs to join purchases.
| Event | Purchase state | Learner state | Access |
|---|---|---|---|
| One-time purchase verified paid | active, refundedAt null | active | Granted |
Installment fails (past_due) | past_due | past_due | Frozen |
| Next invoice paid | active | active | Thawed |
| Plan ended / canceled | canceled | refunded | Revoked |
Refund (markRefunded) | refundedAt set | refunded | Revoked |
| Pay-first rejection | refunded | refunded | Never granted |
The purchase is a financial record: it has no foreign key to the content and survives its deletion. The learner row points to the current backing purchase when paid (null for free/comped access) and also remains after a refund, preserving progress and history with status: refunded. A repurchase updates that pointer; late events from the previous purchase cannot touch the new access state.