Skip to content
cursareDocs
Sign inGet started

Start here

  • Overview
  • Getting started
  • Platform concepts

Create

  • Content
  • Editor and blocks
  • Block reference

Publish & access

  • Publishing and access
  • Custom domain & white-label

Learners

  • Learner experience
  • Learners and enrollments
  • Progress and certificates

Teams & cohort

  • Teams
  • Cohorts

Sales

  • Offers & checkout
  • Payments & payouts
  • Affiliates
  • Links
  • Insights and reports

Admin

  • Organization & security
  • Subscription, trial & billing
  • Appearance
  • Members & roles
  • Audit log

Developer

  • Integrations
  • Learner UI registry
  • API keys & webhooks
  • MCP server
  • API reference
Explore the API

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:

MemberRole
connectedWhether proceeds actually reach the organization; gates every priced checkout
createCheckoutStarts a one-time checkout; returns the redirect URL
createInstallmentPlanStarts an installment plan (N recurring charges); returns the URL
finalizeInstallmentPlanCaps the subscription at exactly N charges (a backstop on the return)
verifyCheckoutRe-reads the session and returns the facts only when paid; null otherwise
refundFully 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.

The truth always comes from the provider, never the browser

Every checkout starts with a purchaseRequest, which is an intent rather than revenue or access. A purchase is recorded only after provider proof matches that request's buyer, organization, offer, amount and currency. Stripe is re-read server-side; external checkouts use signed events. Reprocessing the same provider reference is idempotent.

External checkout protocol

An external integration stores a checkout URL and a one-time HMAC secret. Cursare 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-cursare-timestamp and the lowercase hex HMAC in x-cursare-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: direct school charges

When an organization connects its own Stripe Express account, it is the seller and every checkout, charge, subscription, and refund runs directly in that connected account. Cursare does not retain a percentage or fixed transaction fee from course sales; staff seats and the active-enrollment-peak range are billed separately. See Integrations.

There is no later transfer created by Cursare. Money first enters the school's Stripe pending balance, becomes available according to payment-method settlement, and follows an automatic daily payout schedule delayed by 8 days. Cursare reconciles this setting before paid checkout is enabled.

  • Express account. The organization completes identity, bank account, and provider requirements in Stripe-hosted onboarding. Charges, transfers, and payouts must be enabled.
  • No per-organization secret. Only the acct_… id is kept in the integration row (kind payments, provider stripe); 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.
  • Balance and payout. “Pending” is still settling; “available” can fund refunds or go to the bank on the next payout. The delay reduces early payout risk but does not remove the seller's refund duty.
  • Refund. Stripe debits the connected account's balance. An insufficient balance can become negative and may be offset by future funds or require remediation. Keep an operating reserve and monitor failed requests.
  • Provider reference. See Stripe's official docs on connected-account balances and Connect risk management.

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 price you provide is the total, divided here.
  • Amount per installment: the sum is exact. Installments use floor(total / N) and every remainder cent goes into the first charge. For example, 100.01 in 3 payments becomes 33.35 + 33.33 + 33.33.
  • Recorded revenue: the first session's amount_total is only the first installment; Cursare records the plan's full amount (kept in metadata as fullPrice) in purchase.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"]).

Installments require configured webhooks

Without a verifiable webhook, the N-charge cap would only be ‘best effort’ and the subscription could charge forever. That's why creating an installment plan is refused when webhooks aren't configured (‘Installment plans are unavailable right now’).

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 becomes past_due and 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, Cursare re-reads the subscription directly from Stripe as the source of truth and maps the status to the internal vocabulary:

Stripe statusPurchase state
active, trialingactive (current)
past_due, unpaid, incompletepast_due (in arrears)
canceled, incomplete_expiredcanceled (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 school's connected account, and cancellation is idempotent.

Refunds and the money-before-access guarantee

In Brazil, online purchases have a statutory withdrawal period of at least 7 days under Consumer Defense Code article 49 and Decree 7,962/2013. A school may offer 15, 21, or 30 days, never less than 7.

  • Inside the window. The buyer requests it in Account → Purchases & refunds, without a reason or school approval. Access and public certificate validity are blocked immediately; completion and progress do not remove the right. The refund is full and returns through the original payment method.
  • After the window. The same channel creates a support request. Access stays active until an authorized finance member approves or denies with a reason; this request does not promise an automatic refund.
  • Access starts later. If a paid offer requires approval, the deadline is whichever is later and more favorable to the buyer: the deadline set at payment or the one recalculated when access is granted.
  • Durable record. The purchase preserves the seller, offer, price, installments, access terms, deadline, request, decision, returned amount, and a learning snapshot. The school remains responsible for support and its obligations as seller.

requested is persisted before the provider call; processing blocks access; failed stays retryable; refunded appears only after Stripe confirms; and denied applies only to post-window support. The purchase id is the idempotency key, so retries cannot return money twice.

A duplicate charge that loses the race to another active purchase also becomes a canceled financial record with its safeguard refund already requested. It grants no new access, does not consume the campaign or affiliate commission again, and remains visible in Refunds for retry if Stripe fails.

  • 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 refundedAt and, only if this exact purchase still backs the enrollment, moves the learner to refunded (the row remains, with progress and history, but every access predicate excludes it). The pay-first request also stays as history; refundedAt only removes it from refund work. The operation still decrements the coupon redemption and fires purchase.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 — Cursare 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.

  1. Before the deadline: the team can approve or reject normally.
  2. 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.
  3. On the next daily run: a Vercel Cron Function authenticated by CRON_SECRET selects the expired request.
  4. State first: Cursare persists the request and blocks access before touching the provider.
  5. Provider second: it requests the refund with the purchase id as idempotency key and only after confirmation sets refundedAt, adjusts the coupon, and fires purchase.refunded.
  6. On failure: the purchase remains failed, never claims the money returned, and can be retried with the same key.

30 days blocks approval; the cron job performs the refund

Approval is blocked exactly at createdAt + 30 days. Because the cron runs once a day, the provider call happens on the first run after expiry. Stripe supports this automatic refund. The generic external adapter does not issue refunds by itself: the connected provider must implement refund, or the operation must be completed in the external system.

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 Cursare 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):

FieldWhat it is
Legal nameThe name the buyer entered for the invoice
Tax idThe number itself (a CPF, a VAT number, …)
Tax id typeThe country-specific type — br_cpf, br_cnpj, eu_vat, us_ein, …
CountryThe buyer's country, when collected

You read this data in three places:

WhereUse
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 webhookReact in real time as each sale is recorded

Cursare captures, you (or your tool) issues

Cursare records the buyer's tax identity; it does not emit the fiscal document itself. Feed the captured data to your accountant, your own invoicing software, or an automation (via the API or the webhook). Free enrollments aren't sales, so they carry no fiscal data.

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.

EventPurchase stateLearner stateAccess
One-time purchase verified paidactive, refundedAt nullactiveGranted
Accepted withdrawal, provider pendingrequested / processingrefund_pendingBlocked
Installment fails (past_due)past_duepast_dueFrozen
Next invoice paidactiveactiveThawed
Plan ended / canceledcanceledrefundedRevoked
Refund (markRefunded)refundedAt setrefundedRevoked
Pay-first rejectionrefundedrefundedNever 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.

Related

Pricing & checkoutSetting a price, running checkout, coupons, and the pay-first approval flow.IntegrationsConnect Stripe or an external checkout and configure signed payment events.InsightsVolume, revenue, and offer behavior, with refunded purchases excluded from the totals.
PreviousOffers & checkoutNextAffiliates

Your privacy choices

The technical Tag Manager container is required to apply your choices. Analytics and marketing cookies and storage remain optional and stay off until you authorize them. Privacy Policy.

Sign-in, security, core functions and consent enforcement.

Audience measurement such as Google Analytics.

Marketing storage, campaign attribution and optional tags.