Payments API
Endpoint reference for paying for things on Meetyy. For how the payment engine works internally — the ledger, webhooks, settlers, idempotency — see payment-system.md. For reading payments back afterwards — invoices, receipts, refund state, billing summaries — see billing-api.md. For the Stripe Dashboard configuration this depends on, see stripe-setup.md.
All money is collected into the platform's own Stripe account. Buyers pay through
Stripe Checkout: the API returns a hosted checkout_url, the client opens it,
and Stripe returns the buyer to the frontend afterwards.
Access is never granted by the redirect. It is granted by the webhook. A buyer who pays and immediately closes the tab still gets what they bought, and a buyer who reaches the success URL without paying gets nothing.
The checkout flow
1. POST an "enroll" / "join" / "payment-link" endpoint
↓ API creates a payment record and opens a Stripe Checkout Session
2. Response carries `checkout_url`
↓ client opens it (browser / in-app browser)
3. Buyer pays on Stripe's hosted page
↓ Stripe redirects to APP_FRONTEND_URL/payments/success
↓ Stripe POSTs `checkout.session.completed` to /stripe/webhook
4. Webhook settles the payment and grants access
↓ client re-reads the resource to see the new state
Step 4 is authoritative, step 3 is not. After the redirect, re-read the
enrollment / membership / booking rather than assuming success — or ask
GET /payments/status?session_id=… directly, which the success URL already
carries the id for.
GET /api/v1/payments/status?session_id=cs_test_…
→ { status, is_settled, is_outstanding, invoice_id, reference, purpose, purchasable_id }
Only is_settled may be rendered as "Payment successful"; is_outstanding means
keep polling. Scoped to the authenticated buyer — someone else's session id is a
404. See billing-api.md.
Statuses a client should handle
| Payment status | Meaning |
|---|---|
pending |
Checkout opened, buyer has not paid |
processing |
Async method (e.g. bank debit) — money in flight, access not granted |
succeeded |
Paid; access granted |
failed |
Card declined |
cancelled |
Buyer abandoned, or a newer checkout superseded this one |
expired |
Checkout window closed |
refunded / partially_refunded |
Money returned; a full refund revokes access |
Courses
Quote a price
GET /api/v1/mentee/courses/{course}/pricing?community_id={uuid}
Authorization: Bearer <token>
community_id is optional. Pass it whenever the learner is enrolling through a
community, because community pricing can differ from the public price — omitting
it quotes the public price while enrollment charges the community one.
{
"status": "success",
"message": "Pricing resolved successfully.",
"data": {
"pricing": {
"audience_type": "community_member",
"is_free": false,
"price": "19.00",
"currency": "USD"
}
}
}
Enroll
POST /api/v1/mentee/courses/{course}/enroll
Authorization: Bearer <token>
{ "community_id": "optional-uuid" }
Free course — access is immediate:
{
"status": "success",
"message": "Enrolled successfully.",
"data": {
"enrollment": { "status": "active", "awaits_payment": false, "is_free": true },
"checkout_url": null
}
}
Paid course — 201 with a checkout URL, and no access yet:
{
"status": "success",
"message": "Checkout started. Complete payment to unlock the course.",
"data": {
"enrollment": {
"status": "pending_payment",
"awaits_payment": true,
"is_free": false,
"price_snapshot": "49.00",
"currency": "USD"
},
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..."
}
}
The enrollment moves to active once the payment settles.
Retrying an abandoned checkout is safe. Calling enroll again reuses the same
pending_payment enrollment, refreshes its price, and cancels the previous
checkout — so only one attempt can ever settle. It does not create a duplicate and
does not error.
| Code | When |
|---|---|
201 |
Enrolled, or checkout started |
404 |
Course not found |
422 |
Not published, already enrolled, or community-only without access |
502 |
Gateway unreachable — the enrollment survives, retry the same call |
Errors worth handling
422 You are already enrolled in this course.— only raised for enrollments that grant access. Apending_paymentrow does not block a retry.
Communities
Join
POST /api/v1/mentee/communities/{community}/join
Authorization: Bearer <token>
{ "join_answers": ["..."] }
Response shape is unchanged: { membership, subscription, checkout_url, pending_approval }.
- Free →
membershipactive,checkout_urlnull. - Paid →
membershipnull,subscription.statuspending, and acheckout_url. Membership is granted only once payment settles. - Needs approval →
pending_approval: true, no checkout. The applicant callsjoinagain once approved to get their link.
A subscription community goes through Stripe in subscription mode and renews automatically; a one-time community is a single charge granting lifetime access. Repeating an unpaid join reuses the same pending subscription and supersedes the previous checkout.
Leave
DELETE /api/v1/mentee/communities/{community}/leave
A member who has paid through to a future date keeps access until then. The subscription stays
activewithcancel_at_period_end: true, Stripe stops renewing, and membership ends when the period actually runs out. The response carriesaccess_untilwith that date.One-time purchases and unpaid rows end immediately — there is no period left to honour.
Cancel and resume
POST /api/v1/mentee/communities/{community}/subscription/cancel
POST /api/v1/mentee/communities/{community}/subscription/resume
Cancelling stops the renewal without leaving the community — leave does
both. Same period rule as above: a paid-through member stays active with
cancel_at_period_end: true.
resume calls off a cancellation that has not taken effect yet, and is a 422
once the period has run out — the gateway subscription is gone by then and
re-joining is the only route back. The subscription resource carries can_resume
so a client can tell in advance.
Renewal and lapse
| Stripe event | Effect |
|---|---|
invoice.paid |
Access window extended; a new payment row is recorded, so payments is the billing history |
invoice.payment_failed |
Stripe retries per your dunning settings |
customer.subscription.deleted |
Subscription expired, membership revoked |
An hourly communities:expire-lapsed-subscriptions sweep is the backstop for missed
webhooks and one-time periods Stripe knows nothing about.
Those per-renewal rows are what GET /mentee/invoices lists — a member who has
paid for fourteen months has fourteen invoices, which is what a billing dispute
needs. See billing-api.md.
Sessions
GET /api/v1/mentee/bookings/{id}/payment-link
Returns { payment_url, expires_at }. This is the retry path — POST /mentee/bookings
already returns the same pair for a new paid booking, because bookings are pay-first.
expires_at tracks the slot hold — the gateway stops accepting payment at the same
moment the seat is released to other mentees. (Stripe requires expiry between 30 minutes
and 24 hours, so a nearly-lapsed hold is clamped up into that window.)
| Code | When |
|---|---|
200 |
Checkout opened |
404 |
Booking not found, or not the caller's |
422 |
Booking costs nothing, has already been paid for, or its seat reservation has lapsed |
502 |
Gateway unreachable — retry |
Paying marks the booking paid, claims the seat, and puts it in front of the mentor. The
meeting link is released when they accept (confirmed). Letting checkout expire cancels
the booking and frees the seat.
Refunds are automatic, and how much goes back depends on whether the session was ever
agreed and who cancelled it. A booking leaving paid — the mentor declined, never
answered, or the mentee cancelled before a decision was made — refunds in full. A
confirmed session refunds in full if the mentor cancels it, and refunds the charge less
the cancellation fee if the mentee cancels more than cancellation_window_hours before it
starts; inside that window the mentor keeps it. A full refund marks the booking refunded
once the gateway confirms; a partial one leaves it cancelled with the payment
partially_refunded. Either way the mentee is told funds arrive within 5 working days. The
full table is in booking-api.md.
The capacity race. A group session's last seat can sell while checkout is open. The charge succeeds, the booking is cancelled, and the payment is refunded automatically (
capacity_conflict), falling back to the admin refund queue only if the gateway refuses.
Admin-issued refunds
POST /api/v1/admin/transactions/{transactionId}/refund
For the refunds no automatic path can decide: a platform failure, a session an admin has
investigated after a mentee disputed it, and draining the awaiting_refund queue when the
gateway refused an automatic one. Requires the admin role.
| Field | Type | Required | Notes |
|---|---|---|---|
note |
string | yes | 3–500 chars. Written to the admin audit log — a refund nobody can explain later is what this guards against |
amount_minor |
integer | no | Minor units. Omit to refund everything still outstanding |
reason |
string | no | A RefundReason value. Defaults to platform_error |
Returns the updated transaction. 422 if the charge never settled, has already gone back
in full, or carries no gateway charge; 502 if the gateway refuses. Every call is recorded
in admin_audit_logs as transaction.refunded, with the amount before and after and the
stated note.
Webhook endpoint
POST /stripe/webhook
Stripe-Signature: t=...,v1=...
Not part of the client API — Stripe calls this directly. It is unversioned and
outside /api so the URL configured in the Stripe dashboard does not move when
the client API is versioned.
Configure STRIPE_WEBHOOK_SECRET before going live. Without it every webhook is
rejected, by design: an unverified endpoint would let anyone mark any order paid.
Subscribe the endpoint to:
checkout.session.completedcheckout.session.expiredpayment_intent.payment_failedcharge.refundedinvoice.paidinvoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deleted
See stripe-setup.md for the dashboard configuration these depend
on — in particular, Stripe must be set to cancel subscriptions after failed
dunning, since customer.subscription.deleted is what revokes community access.
Local development
With no STRIPE_SK set, the API binds an offline gateway and checkout_url points
at a local page with Pay and Cancel buttons that drive the same settlement
path. The whole purchase flow is exercisable with no Stripe account.
STRIPE_SK= # empty → fake gateway
PAYMENTS_FAKE_GATEWAY=false # true forces the fake gateway even with a key
To test against real Stripe locally:
stripe listen --forward-to localhost:8000/stripe/webhook
# copy the whsec_… it prints into STRIPE_WEBHOOK_SECRET