Files
rootnode-academy/docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md
T
ced4568 80a5f6e457 docs(phase9): document commerce integration and Winter Break launch readiness
New: PHASE9_COMMERCE_AND_LAUNCH.md, WEBSITE_REGISTRATION_AND_CHECKOUT.md,
STRIPE_INTEGRATION_RUNBOOK.md, WINTER_BREAK_LAUNCH_CHECKLIST.md. Updated:
ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (commerce boundary + PHASE 9 BUILT rows),
OFFERING_AND_REGISTRATION_MODEL.md (pricing, the payment/commerce boundary, three-status
principle), WEBSITE_ENROLLMENT_INTEGRATION.md (price fields, real agreement/checkout/webhook
sections replacing Phase 8 placeholders), WINTER_BREAK_CAMP_OPERATIONS.md (real payment step
replacing the manual placeholder).
2026-08-27 09:06:04 -05:00

211 lines
11 KiB
Markdown

# Website → RootNode Registration & Checkout
Audience: whoever builds `rootnodeacademy.com`'s registration/checkout flow for a paid offering
(Winter Break Camp and anything after it). This is the Phase 9 companion to
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md` — read that document first for the shared trust
boundary, authentication header, and the base `/api/integrations/registrations` contract. This
document covers everything Phase 9 adds on top: pricing display, the agreement step, checkout,
the success page, and status lookup.
## The end-to-end flow
```
1. GET /api/public/offerings/:slug — display program, price, availability (no auth, browser-safe)
2. POST /api/integrations/registrations — submit the registration (server-to-server)
3. POST /api/integrations/registrations/:id/agreements — accept the required agreement(s)
4. POST /api/integrations/registrations/:id/checkout — create a hosted checkout session
5. redirect the customer's browser to the returned checkout_url
6. RootNode's webhook confirms payment — NOT something the website calls
7. GET /api/integrations/registrations/:id/status — poll from the success page
```
Steps 2-4 and 7 are server-to-server only, using the same `X-RootNode-Integration-Key` header as
every other integration route. Steps 1 and 5 are the only things that ever touch the customer's
browser directly.
## 1. Price and availability display
```
GET /api/public/offerings/:slug
```
No auth needed. As of Phase 9, the response includes:
```json
{
"price_amount": 24900,
"price_currency": "usd",
"requires_payment": true
}
```
`price_amount` is an integer in minor currency units (cents) — divide by 100 and format with
`price_currency` for display (`$249.00`). `price_amount: null` means a free offering —
`requires_payment` is `false` and you should skip steps 3/4/5 entirely (call `checkout` on a free
offering and you'll get `409 NO_PRICE_CONFIGURED`; there's nothing to check out).
**This price is display-only.** The actual amount charged always comes from the SAME offering
row, read server-side at checkout-creation time (step 4) — there is no field anywhere in this
contract for the website to submit an amount. A browser (or a compromised website backend)
cannot pay a different price than what's configured on the offering.
## 2. Submit the registration
Unchanged from `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` §2 — adult or minor/guardian flow, same
`event_id` idempotency. You'll get back a `registration_id`; hold onto it for every step below.
## 3. Accept the agreement(s)
```
POST /api/integrations/registrations/:id/agreements
Header: X-RootNode-Integration-Key: <secret>
Body: { "agreement_id": "winter-camp-waiver-2026" }
```
Ask RootNode for the current `agreement_id`(s) configured on the offering — there is no
"list required agreements" endpoint on this public contract yet; if your flow needs to render the
agreement text/checkbox dynamically, ask the RootNode team before building a workaround.
Which party is recorded as accepting is **server-derived from the registration's own
`is_minor`** (set at submission in step 2) — you never send who's accepting. `201` on success:
```json
{ "accepted": true, "agreement_id": "winter-camp-waiver-2026", "accepted_version": 1 }
```
`404 AGREEMENT_NOT_FOUND` if the id is wrong or not configured on this offering.
**Checkout is blocked until every required agreement is accepted** (`409 AGREEMENTS_INCOMPLETE`
from step 4). Call this before checkout, always.
## 4. Create the checkout session
```
POST /api/integrations/registrations/:id/checkout
Header: X-RootNode-Integration-Key: <secret>
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
```
Both URLs are **yours** — RootNode's payment provider redirects the customer's browser back to
one of them after checkout. `success_url` and `cancel_url` are required; nothing else is. There
is no `amount` field — see "Price and availability display" above.
Success (`201`):
```json
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_...", "transaction_id": "..." }
```
Redirect the customer's browser to `checkout_url` immediately (a `302`, or a client-side
`window.location`). In local/staging use with the deterministic test provider (see
`docs/STRIPE_INTEGRATION_RUNBOOK.md`), `checkout_url` is a fake URL pointing straight back at
your own `success_url` with a `?test_session=...` param appended — there is no real hosted page
to visit, which is fine for automated testing but means you can't manually click through a
"real" checkout page against the test provider.
**Errors:**
| Status | `error` | Meaning |
|---|---|---|
| `401` | `unauthorized` | Missing/wrong integration key. |
| `400` | `VALIDATION` | `success_url`/`cancel_url` missing. |
| `404` | `NOT_FOUND` / `OFFERING_NOT_FOUND` | Bad registration id, or its offering vanished. |
| `409` | `NO_PRICE_CONFIGURED` | This offering is free — don't call checkout for it. |
| `409` | `AGREEMENTS_INCOMPLETE` | Call step 3 first. |
| `409` | `INVALID_STATE` | The registration is already past the point where checkout makes sense (e.g. already confirmed). |
| `409` | `OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` / `REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED` | The offering/registration window state changed since step 2. |
| `503` | `PROVIDER_NOT_CONFIGURED` | RootNode's payment provider isn't configured yet — a RootNode-side problem, not yours. Show a generic "checkout is temporarily unavailable" message and alert RootNode. |
## 5. Payment confirmation — never trust the redirect
**RootNode's own webhook is the only thing that ever confirms payment.** When the payment
provider redirects the customer back to your `success_url`, that redirect proves nothing on its
own — a customer can reload, bookmark, or manually construct that URL without ever paying, and a
successful redirect can race ahead of the webhook actually being processed. **Never render "you're
enrolled" or grant anything based on the URL/query string alone.**
## 6. Building a safe success page
```
GET /api/integrations/registrations/:id/status
Header: X-RootNode-Integration-Key: <secret>
```
Call this from your OWN backend (never expose the integration key to the browser) when the
customer lands on your success page, and poll it every couple of seconds for a short window (the
webhook is normally processed within a second or two of the customer completing checkout — see
`docs/STRIPE_INTEGRATION_RUNBOOK.md` for real-world Stripe delivery latency). Response:
```json
{
"registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd",
"status": "confirmed",
"payment_status": "paid",
"enrollment_activated": false
}
```
Deliberately minimal — no name, no email, no amount, no provider reference. Enough to render one
of three states on your success page:
- **`payment_status: "paid"` (or `status: "enrollment_activated"`)** — "You're all set — check
your email for your RootNode Academy activation link."
- **`payment_status: "pending"` or missing, `status` still pre-confirmation** — "Processing your
payment — this page will update automatically" (keep polling, or tell the customer to check
back / check email shortly).
- **`payment_status: "failed"`** — "Your payment didn't go through. You can try again;" — for a
priced offering, a failed payment does NOT release the seat, so the SAME registration can
retry checkout (back to step 4) without losing its place.
A cancelled/expired checkout (customer closed the tab, or the provider's own session timeout)
shows up as `status: "cancelled"` once RootNode's reservation reaper or the provider's own
"expired" webhook processes it — at that point the seat has been released and the customer needs
to start a new registration if they still want in.
`401` (bad/missing key) and `404 NOT_FOUND` (bad registration id) are the only error responses.
## 7. What you must NOT do
- Never store, log, or transmit card numbers, CVVs, or any other payment-card data — this
contract has no field for it anywhere, on purpose. RootNode never touches card data either;
Stripe's hosted Checkout page collects it directly.
- Never send an `amount`/`price` in the checkout request — there is no such field, and if you
add one to your own request body it is silently ignored (the price always comes from the
offering).
- Never build your own "payment succeeded" email — RootNode's activation/confirmation email
fires automatically on webhook-confirmed conversion (same machinery `docs/
WEBSITE_ENROLLMENT_INTEGRATION.md` already documents). A second email from your side would
duplicate or conflict with it.
- Never poll the status endpoint indefinitely/aggressively — a reasonable few-second interval
for a short window (a minute or two) covers real webhook latency; beyond that, tell the
customer to check their email and stop polling.
## Accessibility and loading-state notes
- The redirect to `checkout_url` in step 4 is a full navigation, not a modal — treat it like any
other outbound link (announce the navigation to assistive tech, don't trap focus expecting a
return).
- The success page in step 6 has three real states to design for (processing/confirmed/failed),
not two — see step 6's own state list. Design a genuine "still processing" state; don't leave
the customer looking at a page that appears frozen while you poll.
- Every checkout/agreement error in the tables above has a distinct, human-meaningful cause —
map each to a real, specific message rather than one generic "something went wrong."
## Test-mode flow (what to actually test against)
Local/staging integration testing uses `PAYMENT_PROVIDER=test` (RootNode's own deterministic
provider, no Stripe account needed) — see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for how to drive a
full checkout → webhook → confirmation cycle against it, including simulating a failed payment
and an abandoned checkout. Once RootNode has real Stripe credentials, staging can switch to
`PAYMENT_PROVIDER=stripe` in Stripe's OWN test mode (fake test cards, e.g. `4242 4242 4242 4242`)
for a true end-to-end rehearsal before anything goes live with real money.
## Privacy note
The only personal data this contract ever sees is what `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`
already documents for registration (names, emails, phone) — nothing payment-specific is added.
Whatever billing/contact information Stripe's hosted Checkout collects from the customer (name,
email, card) stays with Stripe; RootNode never receives or stores it beyond the transaction
metadata `docs/PHASE9_COMMERCE_AND_LAUNCH.md` describes (amount, currency, status, timestamps,
provider reference — never card data).