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).
396 lines
20 KiB
Markdown
396 lines
20 KiB
Markdown
# Website → RootNode Academy Enrollment Integration
|
|
|
|
Audience: whoever builds `rootnodeacademy.com` (marketing, pricing, checkout) and needs to turn
|
|
a successful purchase/signup into a real RootNode Academy student enrollment. This document is
|
|
meant to be enough on its own — you should not need to read the Platform API's source to
|
|
integrate against it.
|
|
|
|
## Responsibility boundary
|
|
|
|
**The website is responsible for:** marketing, program selection, pricing, checkout/payment (now
|
|
or in the future), collecting enrollment/customer information, and sending ONE verified
|
|
enrollment event to the endpoint below once payment/signup is confirmed.
|
|
|
|
**RootNode's platform is responsible for everything after that:** student identity, program
|
|
entitlement, account activation, the entire password lifecycle, all operational enrollment
|
|
email (activation, enrollment confirmation, password reset — see
|
|
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`), learning, Labs, progress, assessment, and
|
|
completion. **The website does not send email for platform activation or any other account
|
|
lifecycle event — RootNode owns that entirely, as of Phase 5C.** Do not build a parallel
|
|
"welcome" or "here's how to log in" email on the website side; it would either duplicate or
|
|
conflict with what RootNode already sends automatically.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
rootnodeacademy.com (marketing, pricing, checkout — built separately, not in this repo)
|
|
|
|
|
| your backend verifies payment/signup with your payment provider
|
|
v
|
|
your backend calls the RootNode Enrollment API (below) — server-to-server only
|
|
v
|
|
RootNode Academy Platform API
|
|
creates/matches a student account
|
|
creates or reuses (idempotently) an enrollment
|
|
v
|
|
the student logs in at the RootNode Academy platform and sees their program
|
|
```
|
|
|
|
**RootNode never talks to your payment provider, and your payment provider never talks to
|
|
RootNode.** Your backend is the only thing that calls this endpoint, after it has already
|
|
confirmed payment (or whatever your enrollment trigger is) on its own. This endpoint knows
|
|
nothing about Stripe, PayPal, or any other provider's object model — it only understands a
|
|
small, generic enrollment event described below.
|
|
|
|
## The endpoint
|
|
|
|
```
|
|
POST /api/integrations/enrollments
|
|
```
|
|
|
|
Reachable at the RootNode Academy platform's public origin (the same host your students'
|
|
browsers already use), via HTTPS in any real deployment. This is the **only** endpoint your
|
|
backend needs.
|
|
|
|
## Authentication
|
|
|
|
A pre-shared secret, sent as a header:
|
|
|
|
```
|
|
X-RootNode-Integration-Key: <the secret>
|
|
```
|
|
|
|
- This is **not** a student or admin login. It has nothing to do with cookies, sessions, or
|
|
browser auth of any kind.
|
|
- **The secret must live only in your backend's server-side configuration.** It must never be
|
|
embedded in any browser-served JavaScript, mobile app bundle, or client-side config — anyone
|
|
who obtains it can create enrollments.
|
|
- A request with a missing or wrong key gets `401 { "error": "unauthorized" }` and nothing else
|
|
happens — no student is looked up, nothing is created.
|
|
- RootNode will provide you with the production secret out of band (not over email/chat in
|
|
plaintext — ask the RootNode team for the current secure-sharing process). Rotate it by
|
|
asking RootNode to issue a new one; there is no self-service rotation yet.
|
|
|
|
## Request
|
|
|
|
```json
|
|
{
|
|
"event_id": "order_9f8e7d6c5b4a",
|
|
"source": "website",
|
|
"program_slug": "root-kubernetes-foundations",
|
|
"status": "paid",
|
|
"student": {
|
|
"email": "[email protected]",
|
|
"login_identifier": null,
|
|
"display_name": "Jamie Rivera",
|
|
"contact_email": null
|
|
}
|
|
}
|
|
```
|
|
|
|
| Field | Required | Notes |
|
|
|---|---|---|
|
|
| `event_id` | **yes** | A string, unique per real-world event, from *your* system (an order ID, a payment intent ID — anything stable). This is the idempotency key — see below. Not a RootNode-generated value. |
|
|
| `source` | no | Free text, e.g. `"website"`. Recorded for audit only, defaults to `"integration"` if omitted. |
|
|
| `program_slug` | **yes** | The RootNode program's id, e.g. `"root-kubernetes-foundations"`. Ask RootNode for the current list — there is no public program-catalog endpoint. |
|
|
| `status` | **yes** | One of: `"paid"`, `"active"`, `"confirmed"` (all mean "grant access now") or `"pending"`, `"awaiting_payment"` (means "record the enrollment but don't grant access yet"). This is a small, fixed, provider-neutral vocabulary — translate whatever your payment provider says into one of these five values before calling. Anything else is rejected. |
|
|
| `student.email` | one of email/login_identifier required | The student's own email, if they have one. |
|
|
| `student.login_identifier` | one of email/login_identifier required | Use this instead of/alongside email for a student who doesn't have their own email address (a minor enrolled by a parent/guardian — see below). Any stable, unique string you control — e.g. an internal user ID from your own system works well. |
|
|
| `student.display_name` | no | Shown in the RootNode instructor portal and, once the student logs in, to themselves. Falls back to the email or login_identifier if omitted. |
|
|
| `student.contact_email` | no | **Only** for a student with no email of their own (§ below) — where to send THIS enrollment's activation/confirmation email (typically a guardian's address collected at checkout). Never treated as the student's own identity/login. |
|
|
|
|
### What you must NOT send
|
|
|
|
- No card numbers, CVVs, bank details, or any other payment-card data. This endpoint has no
|
|
field for it and never will — RootNode never touches payment-card data at all.
|
|
- No raw provider webhook payloads (Stripe event objects, PayPal IPN bodies, etc.) — translate
|
|
to the shape above first. This endpoint is deliberately provider-neutral.
|
|
- No password. RootNode issues its own credential/activation flow (see "What the student
|
|
experiences" below) — you cannot set a student's password through this endpoint, and should
|
|
never try to invent one.
|
|
|
|
## Response
|
|
|
|
**Success — a new enrollment was created** (`201`):
|
|
```json
|
|
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": true }
|
|
```
|
|
|
|
**Success — this exact `event_id` was already processed** (`200`, idempotent replay — see below):
|
|
```json
|
|
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": false }
|
|
```
|
|
|
|
**Errors:**
|
|
|
|
| Status | Body `error` | Meaning |
|
|
|---|---|---|
|
|
| `401` | `unauthorized` | Missing/wrong `X-RootNode-Integration-Key`. |
|
|
| `400` | `event_id is required` | — |
|
|
| `400` | `program_slug is required` | — |
|
|
| `400` | `status must be one of: paid, active, confirmed, pending, awaiting_payment` | — |
|
|
| `400` | `student.email or student.login_identifier is required` | — |
|
|
| `404` | `PROGRAM_NOT_FOUND` | `program_slug` doesn't exist. |
|
|
| `409` | `PROGRAM_INACTIVE` | The program exists but isn't currently open for enrollment. |
|
|
| `500` | `internal_error` | Something unexpected broke server-side. Safe to retry (see below). |
|
|
|
|
## Idempotency and retries
|
|
|
|
**`event_id` is the whole mechanism.** Send the exact same `event_id` again (your payment
|
|
provider retried a webhook, your own backend retried a timed-out request, anything) and you get
|
|
back the **same enrollment**, `created: false`, `200` instead of `201` — never a duplicate. This
|
|
is enforced at the database level (a unique constraint), not just "best effort," so it's safe
|
|
under concurrent/parallel retries too, not just sequential ones.
|
|
|
|
**Retry policy:** on a `5xx` or a network error/timeout, retry with the *same* `event_id`
|
|
(standard exponential backoff is fine — a few attempts over a couple of minutes is plenty). On a
|
|
`4xx`, do not blindly retry — fix the request (see the error table above) or, for `401`, check
|
|
your key.
|
|
|
|
**Never generate a new `event_id` for a retry of the same real-world event.** Doing so would
|
|
create a second enrollment. Reserve a fresh `event_id` only for a genuinely new enrollment event
|
|
(e.g. a renewal, a second purchase).
|
|
|
|
## Student matching rules
|
|
|
|
RootNode looks for an existing student by `email` (case-insensitive) or `login_identifier`
|
|
(exact match), in that priority. If found, that student is enrolled. If not found, a new student
|
|
account is created automatically — this is a trusted server-to-server call from your backend,
|
|
equivalent to an instructor adding a student through the admin portal, not a public signup form.
|
|
|
|
## Minor / no-email students
|
|
|
|
RootNode explicitly supports students without their own email address (younger students,
|
|
enrolled by a parent/guardian). Send `student.login_identifier` instead of (or in addition to)
|
|
`email` — any stable string you control, such as an internal account/order reference. You must
|
|
send **at least one** of `email` or `login_identifier`; sending neither is rejected.
|
|
|
|
If the enrolled student has no email at all, send `student.contact_email` too (e.g. the
|
|
guardian's address collected at checkout) so RootNode has somewhere to deliver the activation
|
|
email — otherwise the enrollment is created but nobody is notified automatically, and an
|
|
instructor has to relay the activation link manually. `contact_email` is stored only against
|
|
that specific enrollment, never copied into the student's own identity record.
|
|
|
|
**As of Phase 7, a real guardian/parent record now exists** (`guardians`/`student_guardians` —
|
|
see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`), but this endpoint's contract is UNCHANGED and does
|
|
not yet create or link one — `student.contact_email` still works exactly as described above,
|
|
stored only on the enrollment. If you need "a parent manages multiple children's enrollments" as
|
|
a first-class concept in this endpoint, talk to the RootNode team before building around a
|
|
workaround; the schema now has the real tables for it, they're just not wired into this
|
|
endpoint yet.
|
|
|
|
## Offerings and registrations (Phase 8 — live)
|
|
|
|
As of Phase 8, RootNode represents specific purchasable deliveries — camps, cohorts, self-paced
|
|
access — as first-class **offerings**, with a **registration** step before final academic
|
|
enrollment (see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). This section covers the real, live
|
|
contract; the endpoint documented above (`/api/integrations/enrollments`, direct program
|
|
enrollment) is completely unaffected and still works exactly as described.
|
|
|
|
### 1. List/view offerings (public, no auth)
|
|
|
|
```
|
|
GET /api/public/offerings
|
|
GET /api/public/offerings/:slug
|
|
```
|
|
|
|
No `X-RootNode-Integration-Key` needed — this is a public, read-only endpoint, safe to call
|
|
directly from a browser. Only `published`/`enrollment_open`/`full`/`in_progress` offerings ever
|
|
appear; `draft` and `cancelled` never do. Example response:
|
|
|
|
```json
|
|
{
|
|
"slug": "winter-linux-systems-camp-2026",
|
|
"name": "Winter Break Linux & Systems Foundations Camp",
|
|
"description": "A hands-on introduction to Linux administration and networking.",
|
|
"program_id": "root-kubernetes-foundations",
|
|
"delivery_mode": "camp",
|
|
"recommended_min_age": 13,
|
|
"recommended_max_age": null,
|
|
"starts_at": "2026-12-15T14:00:00.000Z",
|
|
"ends_at": "2026-12-19T18:00:00.000Z",
|
|
"registration_open": true,
|
|
"availability": "limited",
|
|
"location_name": "Community Room B",
|
|
"location_type": "community_center",
|
|
"price_amount": 24900,
|
|
"price_currency": "usd",
|
|
"requires_payment": true
|
|
}
|
|
```
|
|
|
|
`availability` is `"open"` / `"limited"` / `"full"` — never a raw seat count (avoids inviting
|
|
scraping/timing games against a public endpoint). No internal notes, no organization contact
|
|
info, no instructor identity — just enough to build a pricing/availability display. **As of Phase
|
|
9**: `price_amount` is an integer in minor currency units (cents — `24900` = $249.00), `null` for
|
|
a free offering; `price_currency` is a lowercase ISO code (`"usd"`); `requires_payment` is a
|
|
plain derived boolean (`price_amount !== null`) so the website never has to reimplement that
|
|
check. The actual checkout amount is always read server-side from this same offering row at
|
|
checkout-creation time — the website never sends an amount anywhere in this contract.
|
|
|
|
### 2. Select an offering, then submit a registration (server-to-server)
|
|
|
|
```
|
|
POST /api/integrations/registrations
|
|
Header: X-RootNode-Integration-Key: <the same secret /api/integrations/enrollments already uses>
|
|
```
|
|
|
|
Same trust boundary as the existing enrollment endpoint (Step 15) — server-to-server only, never
|
|
called from browser JS, the secret never leaves your backend's own configuration.
|
|
|
|
**Adult example request:**
|
|
|
|
```json
|
|
{
|
|
"event_id": "reg_9f8e7d6c5b4a",
|
|
"source": "website",
|
|
"offering_slug": "winter-linux-systems-camp-2026",
|
|
"registrant": { "display_name": "Jamie Rivera", "email": "[email protected]", "is_minor": false }
|
|
}
|
|
```
|
|
|
|
**Minor/guardian example request:**
|
|
|
|
```json
|
|
{
|
|
"event_id": "reg_1a2b3c4d5e6f",
|
|
"source": "website",
|
|
"offering_slug": "winter-linux-systems-camp-2026",
|
|
"registrant": { "display_name": "Riley Rivera", "is_minor": true },
|
|
"guardian": { "display_name": "Jamie Rivera", "email": "[email protected]", "phone": "555-0100" }
|
|
}
|
|
```
|
|
|
|
**Success response** (`201` new, `200` idempotent replay):
|
|
|
|
```json
|
|
{ "registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "offering_id": "winter-linux-systems-camp-2026", "status": "started", "created": true }
|
|
```
|
|
|
|
**Errors**: `401 unauthorized` (bad/missing key), `400` (`offering_slug`/`event_id`/registrant
|
|
fields missing, or a minor with no guardian email), `404 OFFERING_NOT_FOUND`, `409` for
|
|
`OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` /
|
|
`REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED`.
|
|
|
|
### 3. Duplicate/retry semantics (idempotent, DB-enforced)
|
|
|
|
Identical to `/api/integrations/enrollments`: send the exact same `event_id` again (a retried
|
|
webhook, a timed-out request retried by your own backend) and you get back the **same
|
|
registration**, `created: false`, never a duplicate, never a second seat reserved — enforced by
|
|
a real unique index on `event_id`, safe under concurrent/parallel retries too. Never generate a
|
|
new `event_id` for a retry of the same real-world submission.
|
|
|
|
### 4. Adult flow
|
|
|
|
Send `registrant.email`, omit `guardian` entirely, `is_minor: false` (or omit it). The adult IS
|
|
both the eventual student and the registration contact — no guardian record is created.
|
|
|
|
### 5. Minor/guardian flow
|
|
|
|
Send `registrant.is_minor: true` and a real `guardian` object (`display_name` + `email`
|
|
required; `phone` optional). RootNode matches an existing guardian by email first (a returning
|
|
family doesn't get a duplicate guardian record per sibling/per camp) or creates a new one. The
|
|
guardian's email is never written to the eventual student's own identity record.
|
|
|
|
### 6. Agreement flow (Phase 9 — live)
|
|
|
|
```
|
|
POST /api/integrations/registrations/:id/agreements
|
|
Body: { "agreement_id": "winter-camp-waiver-2026" }
|
|
```
|
|
|
|
Same shared-secret header as every other route in this section. Which party accepts is
|
|
server-derived from the registration's own `is_minor`, never a value you send: an adult accepts
|
|
as themselves (before their student account even exists yet), a minor's registration is accepted
|
|
by the guardian already on file from submission. `201 { "accepted": true, "agreement_id": ...,
|
|
"accepted_version": ... }` on success; `404 AGREEMENT_NOT_FOUND` if the id doesn't match a real
|
|
agreement configured on this offering. **Checkout is blocked (`409 AGREEMENTS_INCOMPLETE`) until
|
|
every required agreement is accepted** — call this before calling checkout below.
|
|
|
|
### 7. Checkout (Phase 9 — live)
|
|
|
|
```
|
|
POST /api/integrations/registrations/:id/checkout
|
|
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
|
|
```
|
|
|
|
Server-to-server only — the browser never calls this directly, and never supplies a price. The
|
|
amount, currency, and product description all come from the offering's own server-side
|
|
configuration. Success: `201 { "checkout_url": "...", "transaction_id": "..." }` — redirect the
|
|
customer's browser to `checkout_url` (RootNode's configured payment provider's own hosted
|
|
checkout page; today that's Stripe Checkout in test mode, or a fake URL from the deterministic
|
|
test provider in local dev — see `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`). A free offering
|
|
(`price_amount: null`) returns `409 NO_PRICE_CONFIGURED` — never call this for a free offering;
|
|
just proceed straight to confirmation. Full error table, the success-page design (never trust a
|
|
returning `?paid=true`), and the status-lookup endpoint are in `docs/
|
|
WEBSITE_REGISTRATION_AND_CHECKOUT.md`.
|
|
|
|
### 8. Payment confirmation — a verified webhook, never a browser redirect
|
|
|
|
RootNode's own webhook (not something the website calls) is the ONLY thing that confirms
|
|
payment. Once confirmed, the registration walks forward to `confirmed` and converts to a real
|
|
enrollment automatically — reusing the exact activation-email flow described below, no separate
|
|
"payment succeeded" email exists. See `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for how to
|
|
build a safe success page against this (poll the status-lookup endpoint; never derive success
|
|
from the URL alone).
|
|
|
|
### 9. Confirmation and enrollment activation
|
|
|
|
Once `confirmed` (via the webhook above for a priced offering, or a manual admin transition for
|
|
a free one) and every required agreement is accepted, the registration converts to a real
|
|
enrollment — creating or matching the real student account and reusing the exact same
|
|
activation-email machinery `/api/integrations/enrollments` already triggers. No second
|
|
confirmation path and no second activation path exist.
|
|
|
|
### 10. What this endpoint does NOT do
|
|
|
|
No payment-card data (never sent, never has a field for it — see `docs/
|
|
WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the full payment-security posture). No password
|
|
(RootNode issues its own credential/activation flow). No arbitrary registration status
|
|
(server-defined transitions only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example
|
|
data above is fake — do not
|
|
reuse these values against a real deployment.
|
|
|
|
## What the student experiences after you call this (as of Phase 5C)
|
|
|
|
If `status` mapped to "active": RootNode sends the student (or their enrollment's
|
|
`contact_email`) a real email automatically —an activation email with a one-time setup link if
|
|
they're new, or an enrollment confirmation if they already have a RootNode account — and they
|
|
can log in as soon as they act on it. **You do not need to send any email yourself for this** —
|
|
see "Responsibility boundary" above. If `status` mapped to "pending": the enrollment exists but
|
|
the student can't start until a RootNode instructor activates it — useful if your flow needs a
|
|
manual review step before access is granted; no email goes out until activation happens.
|
|
|
|
If the student has neither an `email` nor a `contact_email` on file, RootNode still creates the
|
|
account and enrollment, but nothing gets emailed automatically — an instructor has to relay the
|
|
activation link by hand. Send whichever address you have whenever you have one.
|
|
|
|
## Local/staging testing
|
|
|
|
Set `INTEGRATION_SHARED_SECRET` in your test environment's config (in local dev,
|
|
`platform/.env`; example dev value used by this repo's own test suite:
|
|
`dev-integration-secret-not-for-production` — never use this value anywhere real). Then:
|
|
|
|
```bash
|
|
curl -s -X POST http://localhost:4200/api/integrations/enrollments \
|
|
-H 'Content-Type: application/json' \
|
|
-H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
|
|
-d '{
|
|
"event_id": "test-order-001",
|
|
"source": "website",
|
|
"program_slug": "root-kubernetes-foundations",
|
|
"status": "paid",
|
|
"student": { "email": "[email protected]", "display_name": "Test Student" }
|
|
}'
|
|
```
|
|
|
|
All example data on this page is fake — do not reuse these values against a real deployment.
|
|
|
|
## Production secret handling
|
|
|
|
- The production `X-RootNode-Integration-Key` value must be requested from the RootNode team
|
|
and stored in your backend's secret manager/environment configuration — never in source
|
|
control, never in a browser-reachable config, never logged.
|
|
- RootNode's own logs and audit trail never record the secret's value, only that an
|
|
authenticated call happened (or a `401` if it didn't).
|