MeetyyAPI
Documentation / API Reference / Payments

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 course201 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. A pending_payment row 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 }.

  • Freemembership active, checkout_url null.
  • Paidmembership null, subscription.status pending, and a checkout_url. Membership is granted only once payment settles.
  • Needs approvalpending_approval: true, no checkout. The applicant calls join again 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 active with cancel_at_period_end: true, Stripe stops renewing, and membership ends when the period actually runs out. The response carries access_until with 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 communityleave 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.completed
  • checkout.session.expired
  • payment_intent.payment_failed
  • charge.refunded
  • invoice.paid
  • invoice.payment_failed
  • customer.subscription.updated
  • customer.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