Files
rootnode-academy/docs/WEBSITE_ENROLLMENT_INTEGRATION.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

20 KiB

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

{
  "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):

{ "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):

{ "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:

{
  "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:

{
  "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:

{
  "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):

{ "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:

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).