# Course → Knov.ai redeem API (contract)

**Status:** proposed — build the knov.ai side against this, then implement the tylerewillis.com side.
**Owners:** tylerewillis.com mints + verifies; knov.ai redeems.

## What this is

The OARS Practitioner Course ($497, one-time, on tylerewillis.com) is a **feeder** for the
**OARS Certified Implementor Program** (a separate, paid program that lives entirely on
knov.ai/build). It does **not** grant certification.

When a buyer **completes the course**, tylerewillis.com mints a single-use **reward code** and
emails it to them. Redeeming it at knov.ai gets them, for **12 months from the day they sign up
at knov.ai**:

- **50% off** the Certified Implementor Program, and
- **priority access** (skip any waitlist / gate).

The code itself does not expire — the 12-month term starts when they redeem it at knov.ai.

Source-of-truth split:

- **tylerewillis.com** owns the sale, course progress, "did they complete it," and the reward code.
- **knov.ai** owns certification, the directory, and applying the discount at its own checkout.

Data flows **one direction**: knov.ai **pulls** from tylerewillis.com at redemption time. This site
never knows about knov.ai's user table; knov.ai never reads this site's DB — it asks over HTTP.

## Lifecycle

```
complete course        tylerewillis mints code (random, single-use, stored on the enrollment)
   │                   emails one-click URL:  https://knov.ai/build?ref=<CODE>
   ▼
user clicks ──────────▶ signs up / starts checkout at knov.ai
                           │
        (optional)         │  POST .../api/redeem  {code, mode:"preview"}   ← show "50% off applied"
                           │  ◀── reward details, NOT consumed
                           │
        at commit          │  POST .../api/redeem  {code, mode:"redeem", knov_email}
                           ▼  ◀── reward details, code now bound to knov_email
                        knov.ai applies 50% + priority; tylerewillis marks code redeemed
```

## Endpoint

```
POST https://tylerewillis.com/training/practitioner-course/api/redeem
Content-Type: application/json
Authorization: Bearer <KNOV_PROVISION_KEY>
```

- **Auth:** shared secret in the `Authorization: Bearer` header (`KNOV_PROVISION_KEY`, stored in
  `config/config.php` on this side; in knov.ai's secrets on the other). Missing/wrong → `401`.
  This is in addition to the code itself being unguessable — two independent gates.
- **Transport:** HTTPS only. `Cache-Control: no-store` on every response.
- Mirrors the conventions of the existing
  [`/tools/oars-for-wordpress/api/directory/submit`](../index.php) endpoint (Bearer auth, JSON in/out).

### Request body

| Field        | Type   | Required | Notes |
|--------------|--------|----------|-------|
| `code`       | string | yes      | The reward code from the email. Opaque to knov.ai. |
| `mode`       | string | no       | `"redeem"` (default) consumes the code; `"preview"` validates without consuming. |
| `knov_email` | string | for redeem | The email the user is signing up with at knov.ai. Required when `mode="redeem"`; ignored for preview. Used to **bind** the code so it can't be reused by anyone else. |

### Success — `200`

```json
{
  "valid": true,
  "status": "ok",
  "consumed": true,
  "reward": {
    "type": "certified_implementor_discount",
    "discount_pct": 50,
    "priority": true,
    "term_months": 12
  },
  "enrollee": {
    "name": "Alex Rivera",
    "email": "alex@agency.com"
  },
  "completed_at": "2026-06-01T14:22:09Z",
  "redeemed_at":  "2026-06-05T09:10:00Z",
  "expires_at":   "2027-06-05T09:10:00Z",
  "redeemed_email": "alex@agency.com"
}
```

- `consumed`: `true` after a `redeem`; `false` for a `preview` (valid but not yet used).
- `enrollee.email` is the course-purchase email — may differ from `redeemed_email`. Returned so
  knov.ai can prefill/cross-check, but the **code**, not the email, is the entitlement.
- `completed_at` = when the course was finished (the code was minted).
- `expires_at` = `redeemed_at` + 12 months — the term runs from knov.ai signup, so it's `null` on a
  `preview` (nothing redeemed yet) and set only once the code is redeemed.

### Idempotency

Keyed on `code`:

- **First `redeem`** binds the code to `knov_email`, sets `redeemed_at`, returns the payload above.
- **Repeat `redeem` with the same `knov_email`** → same `200` payload (so knov's signup→checkout can
  call more than once safely).
- **`redeem` with a *different* `knov_email`** → `409` `already_redeemed` (single-use).
- **`preview`** never consumes and can be called any number of times until the code is redeemed or expires.

### Errors

Every error response is JSON with `valid:false` and a stable `status` string.

| HTTP | `status`            | When |
|------|---------------------|------|
| 401  | `unauthorized`      | Missing or wrong bearer secret. |
| 400  | `bad_request`       | Malformed JSON, missing `code`, or `mode="redeem"` without a valid `knov_email`. |
| 404  | `not_found`         | No such code. |
| 409  | `already_redeemed`  | Code already redeemed by a different email. |
| 409  | `not_completed`     | Defensive: a code exists but the enrollment is no longer active (refund/revoke before it was redeemed). |
| 500  | `server_error`      | Unexpected; knov.ai should treat as retryable. |

The code has no expiry of its own — the 12-month term starts at redemption — so there is no
`expired` status.

Example error:

```json
{ "valid": false, "status": "already_redeemed" }
```

knov.ai should treat `404`/`409` as terminal (don't retry; tell the user) and `500` as retryable.

## Code format

- Opaque, URL-safe, high-entropy — **128-bit, hex**: `^[a-f0-9]{32}$`, generated with
  `bin2hex(random_bytes(16))`. (Same CSPRNG family as the existing 64-char `course_login_tokens`.)
- Single-use, bound to one `redeemed_email` on first redeem.
- One code per enrollment (1:1). Re-minting (e.g. resend email) reuses the existing code, doesn't issue a new one.
- knov.ai should treat it as opaque and never parse meaning out of it.

## tylerewillis.com data model (this side, for reference)

Append-only `ALTER TABLE` on `course_enrollments` (per the house rule — never edit the `CREATE TABLE`):

```sql
ALTER TABLE course_enrollments
  ADD COLUMN knov_reward_code      CHAR(32)   NULL,
  ADD COLUMN knov_reward_issued_at TIMESTAMP  NULL,
  ADD COLUMN knov_reward_redeemed_at    TIMESTAMP NULL,
  ADD COLUMN knov_reward_redeemed_email VARCHAR(255) NULL,
  ADD UNIQUE KEY uk_knov_reward_code (knov_reward_code);
```

- **Mint** in `Course::recordQuiz()` (and/or `setStepStatus`) once all required checks across the
  currently-published levels pass — set `knov_reward_code` + `knov_reward_issued_at`, then email it.
- **Redeem** endpoint reads/writes `knov_reward_redeemed_*`.

## Config

| Constant            | Where | Purpose |
|---------------------|-------|---------|
| `KNOV_PROVISION_KEY`| `config/config.php` (this site) + knov.ai secrets | Shared bearer secret for the redeem endpoint. |

## Example

```bash
# preview (does not consume)
curl -sX POST https://tylerewillis.com/training/practitioner-course/api/redeem \
  -H "Authorization: Bearer $KNOV_PROVISION_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code":"9f8c1a2b3d4e5f60718293a4b5c6d7e8","mode":"preview"}'

# redeem (binds to the knov signup email, single-use)
curl -sX POST https://tylerewillis.com/training/practitioner-course/api/redeem \
  -H "Authorization: Bearer $KNOV_PROVISION_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code":"9f8c1a2b3d4e5f60718293a4b5c6d7e8","mode":"redeem","knov_email":"alex@agency.com"}'
```

## Decisions (settled)

1. **Completion bar** — mint on passing every knowledge check in the **currently-published** levels
   (`status != in_progress`); the reward is point-in-time and not re-evaluated when new levels ship.
2. **Refund/revoke** — no revoke signal. A redeemed discount is already spent. An *un*-redeemed code
   on a refunded/revoked enrollment returns `409 not_completed` (the endpoint checks `status='active'`).
3. **knov signup URL** — `https://knov.ai/build?ref=<CODE>`, param `ref`. The completion email links here.
4. **Term** — 12 months from **knov.ai signup** (redemption), not from completion. `expires_at` is
   computed at redeem time and the code has no standalone expiry.

## Still to do on the knov.ai side

- Build the redeem caller into knov.ai's signup/checkout: read `?ref=`, call this endpoint with
  `mode:"preview"` to show "50% off applied," then `mode:"redeem"` with the signup email at commit.
- Store `KNOV_PROVISION_KEY` (value shared out-of-band) in knov.ai's secrets.
