Developer reference

Luminary Public API — v1

The API the Luminary native app runs on. Every handler delegates to the same server logic the web app uses, so quotas, row-level security, and the Codex astrology rules behave identically on both clients. Version 1.0.0.

Base URLs

Full URL = base URL + /api/public/v1 + path.

Production  https://theluminary.xyz/api/public/v1
Preview     https://the-luminary-eight.vercel.app/api/public/v1

Authentication

Every endpoint except GET /api/public/v1/meta requires a Supabase access token taken from the client SDK session. Tokens expire — refresh through the SDK and retry once on a 401.

Authorization: Bearer <supabase access token>
Content-Type: application/json
X-Client-Version: 1.0.0   # optional, helps support

Response envelope

Every response is JSON. Branch on ok, then on error.code — never on the message, which is written for humans and can change. Responses carry X-Api-Version.

Success

{ "ok": true, "data": { } }

Failure

{
  "ok": false,
  "error": {
    "code": "quota_exceeded",
    "message": "You've used all 30 briefings this month."
  }
}

Error codes

CodeHTTPMeaning
unauthorized401Missing, malformed, or expired bearer token.
forbidden403Account suspended or the plan does not include this feature.
invalid_request400Validation failed, a required field is missing, or the referenced record does not exist.
quota_exceeded402The caller's monthly tier quota for this module is spent. Message is human-readable — show it as-is.
not_found404Route or record not found.
upstream_unavailable503An upstream provider (ephemeris, AI gateway) failed or timed out. Retry with backoff.
server_error500Unexpected server fault. Details are logged server-side and never returned.

Quotas, CORS, and versioning

  • AI-backed endpoints (POST /today, POST /journal, POST /chat/messages) consume the caller's monthly tier allowance and return 402 quota_exceeded when spent. Read the tier table from /meta rather than hardcoding it.
  • All v1 routes allow any origin and answer OPTIONS with 204. Allowed headers: Content-Type, Authorization, X-Client-Version, X-Client-Platform.
  • Additive changes ship inside v1 without notice — clients must ignore unknown fields. Breaking changes ship as /api/public/v2.
  • Moon and Rising signs are never fabricated. When incompleteReason is set, prompt for the missing birth detail instead of rendering a placeholder.

Service

GET/api/public/v1/metaPublic

Service descriptor

Unauthenticated. Call once on app launch to discover the API version, plan catalogue, per-tier quotas, the breathwork technique catalogue, and the endpoint list. Cache it for the session so the client never ships a hardcoded copy that can drift from the server.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/meta'

Response 200

{
  "ok": true,
  "data": {
    "apiVersion": "1.0.0",
    "envelope": {
      "success": "{ ok: true, data }",
      "failure": "{ ok: false, error: { code, message } }"
    },
    "auth": "Authorization: Bearer <supabase access token> on every endpoint except /meta",
    "tiers": [
      {
        "id": "seeker",
        "name": "Seeker",
        "tagline": "Start the practice",
        "priceMonthlyCents": 0,
        "priceLabel": "Free",
        "priceId": null,
        "features": ["Daily briefing", "Natal chart"],
        "quotas": { "chat": 10, "journal": 5, "briefing": 30 }
      }
    ],
    "breathworkTechniques": [
      {
        "slug": "box-breathing",
        "name": "Box Breathing",
        "tagline": "Steady the nervous system",
        "goals": ["focus", "calm"],
        "moods": ["anxious", "scattered"],
        "defaultRounds": 6,
        "pattern": { "inhale": 4, "holdIn": 4, "exhale": 4, "holdOut": 4 }
      }
    ],
    "endpoints": [
      { "method": "GET", "path": "/api/public/v1/me", "auth": true, "desc": "Profile, entitlements, billing state" }
    ]
  }
}
GET/api/public/v1/healthPublic

Liveness and optional connectivity probe

Anonymous callers get `{ apiVersion, status: "ok" }` only. Pass a valid `X-Luminary-Ai-Token` to run the full Resend + Supabase + Edge Function probe (includes hook-secret configuration).

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/health'

Response 200

{
  "ok": true,
  "data": {
    "apiVersion": "1.0.0",
    "status": "ok"
  }
}

Account

GET/api/public/v1/meBearer token

Profile, entitlements, and chart completeness

Everything the account screen needs in one call: the profile row, the resolved tier/entitlement, and boolean flags telling the client whether onboarding or extra birth details are still required.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/me' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": {
    "userId": "9c1f7e4a-2b30-4a6d-9f0e-4a1d2c3b5e77",
    "profile": {
      "id": "9c1f7e4a-2b30-4a6d-9f0e-4a1d2c3b5e77",
      "display_name": "Ava",
      "avatar_url": null,
      "pronouns": "she/her",
      "gender_identity": "woman",
      "sexual_orientation": null,
      "identity_note": null,
      "birth_date": "1992-03-21",
      "birth_time": "07:45",
      "birth_location": "Austin, Texas, United States",
      "birth_lat": 30.2672,
      "birth_lng": -97.7431,
      "sun_sign": "Aries",
      "moon_sign": "Pisces",
      "rising_sign": "Taurus",
      "natal_chart_data": { "source": "astrology-api", "planets": [] },
      "onboarding_complete": true,
      "tier": "oracle",
      "access_status": "active",
      "trial_ends_at": null,
      "created_at": "2026-01-14T18:02:11.482Z"
    },
    "chartComplete": true,
    "needsBirthTime": false,
    "needsBirthLocation": false,
    "onboardingComplete": true,
    "entitlement": { "tier": "oracle", "status": "active", "source": "subscription" }
  }
}

Errors

  • 401 unauthorized No bearer token, or the token has expired.
PATCH/api/public/v1/meBearer token

Update birth details and re-cast the chart

Persists any supplied birth fields and immediately recomputes the natal chart. Send only the fields that changed. `birthTime` is 24-hour `HH:MM` (seconds optional and ignored). Supplying `lat`/`lng`/`tz` skips geocoding; otherwise `birthLocation` is geocoded server-side. Sun sign is resolved against the exact ingress table, so cusp birthdays are correct. Moon and Rising are never fabricated — if birth time or location is missing they stay null and the flags below say why.

Body

FieldTypeReq.Description
displayNamestringnoDisplay name.
birthDatestring (YYYY-MM-DD)noBirth date.
birthTimestring (HH:MM)noLocal birth time, 24-hour. Required for Moon/Rising accuracy.
birthLocationstringnoHuman-readable birth place; geocoded when lat/lng are absent.
latnumbernoLatitude, if already resolved client-side.
lngnumbernoLongitude, if already resolved client-side.
tzstring (IANA)noTimezone, e.g. `America/Chicago`.

Request

curl -X PATCH 'https://theluminary.xyz/api/public/v1/me' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "birthDate": "1992-03-21", "birthTime": "07:45", "birthLocation": "Austin, Texas, United States", "lat": 30.2672, "lng": -97.7431, "tz": "America/Chicago" }'

Response 200

{
  "ok": true,
  "data": {
    "sun": "Aries",
    "moon": "Pisces",
    "rising": "Taurus",
    "birth": {
      "date": "1992-03-21",
      "time": "07:45",
      "location": "Austin, Texas, United States",
      "lat": 30.2672,
      "lng": -97.7431
    },
    "needsBirthTime": false,
    "needsBirthLocation": false,
    "cuspWarning": "Born within 12 hours of the Aries ingress — verified against the exact ingress table."
  }
}

Errors

  • 400 invalid_request A field fails validation (e.g. a malformed time or date).
  • 503 upstream_unavailable The ephemeris provider is unreachable and no verified fallback exists.
DELETE/api/public/v1/meBearer token

Permanently delete the signed-in account

App Store guideline 5.1.1(v). Cancels Stripe subscriptions, marks Apple IAP rows canceled locally, then deletes the Auth user. Profile and related rows cascade. Irreversible. The iOS app must call this from Profile — not only open a web page.

Request

curl -X DELETE 'https://theluminary.xyz/api/public/v1/me' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": { "deleted": true }
}

Errors

  • 401 unauthorized No bearer token, or the token has expired.
POST/api/public/v1/auth/apple/notificationsPublic

Sign in with Apple server-to-server notifications

Apple POSTs a signed JWT when a user revokes consent, deletes their Apple Account, or changes Hide My Email forwarding. Paste `https://theluminary.xyz/api/public/v1/auth/apple/notifications` into Apple Developer → App ID → Sign in with Apple → Server-to-Server Notification Endpoint. `consent-revoked` and `account-deleted` delete the matching Luminary user. Not called by the iOS app.

Body

FieldTypeReq.Description
payloadstringyesApple-signed JWS. Verify with https://appleid.apple.com/auth/keys.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/auth/apple/notifications' \
  -H 'Content-Type: application/json' \
  -d '{ "payload": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." }'

Response 200

{
  "ok": true,
  "data": { "received": true, "type": "consent-revoked", "action": "deleted" }
}

Errors

  • 401 unauthorized The JWS signature, issuer, or audience is invalid.

Chart

GET/api/public/v1/chartBearer token

Natal chart

The caller's natal chart. `incompleteReason` is non-null whenever Moon/Rising are absent — render the matching prompt instead of a placeholder sign. Possible values: `missing_birth_time`, `missing_birth_location`, `not_yet_computed`, or `null` when the chart is complete.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/chart' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": {
    "sun": "Aries",
    "moon": "Pisces",
    "rising": "Taurus",
    "planets": [
      { "name": "Sun", "sign": "Aries", "degree": 1.24, "house": 11 },
      { "name": "Moon", "sign": "Pisces", "degree": 18.9, "house": 10 }
    ],
    "incompleteReason": null,
    "birth": {
      "date": "1992-03-21",
      "time": "07:45",
      "location": "Austin, Texas, United States",
      "lat": 30.2672,
      "lng": -97.7431
    },
    "meta": {
      "sunSource": "ref_sun_ingress",
      "cuspWarning": null,
      "source": "astrology-api"
    }
  }
}

Today

GET/api/public/v1/todayBearer token

Home screen aggregate

One round trip for the home screen: profile summary, today's briefing (or `null` if it has not been generated yet), today's actions plus yesterday's still-pending ones, up to five active goals, and any date-significance cards. This endpoint never generates AI content and never spends quota — the client decides when to call `POST /today`.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/today' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": {
    "date": "2026-07-29",
    "profile": { "display_name": "Ava", "sun_sign": "Aries", "moon_sign": "Pisces", "rising_sign": "Taurus" },
    "briefing": {
      "id": "1f2e...",
      "briefing_date": "2026-07-29",
      "headline": "Momentum favours the direct ask",
      "body": "Mars trines your natal Sun...",
      "focus_areas": ["career", "communication"]
    },
    "actions": {
      "today": [
        { "id": "a1b2...", "title": "Send the proposal", "status": "planned", "action_date": "2026-07-29" }
      ],
      "yesterdayPending": []
    },
    "activeGoals": [
      { "id": "g1...", "title": "Ship the app", "category": "career", "status": "active", "progress": 40 }
    ],
    "dateSignificance": [
      { "headline": "Perihelion of comet 12P", "significance": "...", "traditions": ["astronomical"], "category": "historical" }
    ]
  }
}
POST/api/public/v1/todayBearer token

Generate today's briefing

Generates (or returns the already-generated) daily briefing. Counts against the caller's monthly briefing quota. Pass `force: true` to regenerate an existing briefing — that always consumes quota. Optionally pass `significanceCategory` to generate the date-significance card in the same call.

Body

FieldTypeReq.Description
forcebooleannoRegenerate even if today's briefing already exists. Defaults to false.
significanceCategory"spiritual" | "historical" | "mythological"noAlso generate the On This Day card for this category.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/today' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "force": false, "significanceCategory": "historical" }'

Response 200

{
  "ok": true,
  "data": {
    "briefing": {
      "id": "1f2e...",
      "briefing_date": "2026-07-29",
      "headline": "Momentum favours the direct ask",
      "body": "Mars trines your natal Sun...",
      "focus_areas": ["career", "communication"]
    },
    "significance": {
      "headline": "Perihelion of comet 12P",
      "significance": "...",
      "category": "historical"
    }
  }
}

Errors

  • 402 quota_exceeded The tier's monthly briefing allowance is spent. Show `error.message` verbatim and offer an upgrade.
  • 400 invalid_request Onboarding is incomplete or the birth date is missing.

Actions

GET/api/public/v1/actionsBearer token

Today's actions

Returns today's actions and yesterday's actions so the client can offer a carry-forward prompt.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/actions' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": {
    "today": [
      {
        "id": "a1b2c3d4-...",
        "title": "Send the proposal",
        "intention": "Move the Saturn goal forward",
        "status": "planned",
        "action_date": "2026-07-29",
        "linked_goal_id": "g1...",
        "reflection": null,
        "created_at": "2026-07-29T08:12:04.221Z"
      }
    ],
    "yesterday": []
  }
}
POST/api/public/v1/actionsBearer token

Create an action

Creates a daily action. Omit `date` to file it under today.

Body

FieldTypeReq.Description
titlestringyesWhat the user will do.
intentionstringnoWhy it matters — surfaced in reflections.
goalIdstring (uuid)noLink the action to an existing goal.
datestring (YYYY-MM-DD)noDefaults to today in UTC.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/actions' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "title": "Send the proposal", "intention": "Move the Saturn goal forward", "goalId": "g1b2c3d4-...", "date": "2026-07-29" }'

Response 200

{
  "ok": true,
  "data": {
    "id": "a1b2c3d4-...",
    "title": "Send the proposal",
    "intention": "Move the Saturn goal forward",
    "status": "planned",
    "action_date": "2026-07-29",
    "linked_goal_id": "g1b2c3d4-...",
    "created_at": "2026-07-29T08:12:04.221Z"
  }
}

Errors

  • 400 invalid_request `title` is missing or empty.
PATCH/api/public/v1/actions/{id}Bearer token

Update an action

Marks an action done or skipped and/or attaches the user's reflection.

Path parameters

FieldTypeReq.Description
idstring (uuid)yesAction id.

Body

FieldTypeReq.Description
status"planned" | "done" | "skipped"noNew status.
reflectionstringnoFree-text reflection captured on completion.

Request

curl -X PATCH 'https://theluminary.xyz/api/public/v1/actions/a1b2c3d4-0000-0000-0000-000000000000' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "status": "done", "reflection": "Easier than I expected." }'

Response 200

{
  "ok": true,
  "data": {
    "id": "a1b2c3d4-...",
    "title": "Send the proposal",
    "status": "done",
    "reflection": "Easier than I expected.",
    "action_date": "2026-07-29"
  }
}

Errors

  • 400 invalid_request The action does not exist or belongs to another user.
DELETE/api/public/v1/actions/{id}Bearer token

Delete an action

Permanently removes the action.

Path parameters

FieldTypeReq.Description
idstring (uuid)yesAction id.

Request

curl -X DELETE 'https://theluminary.xyz/api/public/v1/actions/a1b2c3d4-0000-0000-0000-000000000000' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{ "ok": true, "data": { "id": "a1b2c3d4-...", "deleted": true } }

Goals

GET/api/public/v1/goalsBearer token

List goals

All of the caller's goals, newest first.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/goals' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": [
    {
      "id": "g1b2c3d4-...",
      "title": "Ship the mobile app",
      "description": "Public beta in the App Store",
      "category": "career",
      "aligned_planet": "Saturn",
      "status": "active",
      "progress": 40,
      "target_date": "2026-10-01",
      "created_at": "2026-06-02T14:20:00.000Z"
    }
  ]
}
POST/api/public/v1/goalsBearer token

Create a goal

Creates a goal. `category` defaults to `general` when omitted.

Body

FieldTypeReq.Description
titlestringyesGoal title.
descriptionstringnoLonger description.
categorystringnoe.g. `career`, `health`, `relationships`. Defaults to `general`.
alignedPlanetstringnoPlanet the goal is aligned to, e.g. `Saturn`.
targetDatestring (YYYY-MM-DD)noTarget completion date.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/goals' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "title": "Ship the mobile app", "description": "Public beta in the App Store", "category": "career", "alignedPlanet": "Saturn", "targetDate": "2026-10-01" }'

Response 200

{
  "ok": true,
  "data": {
    "id": "g1b2c3d4-...",
    "title": "Ship the mobile app",
    "category": "career",
    "aligned_planet": "Saturn",
    "status": "active",
    "progress": 0,
    "target_date": "2026-10-01"
  }
}
PATCH/api/public/v1/goalsBearer token

Update goal progress or status

The goal id travels in the body, not the path.

Body

FieldTypeReq.Description
idstring (uuid)yesGoal id.
progressnumber (0-100)noCompletion percentage.
status"active" | "paused" | "complete"noNew status.

Request

curl -X PATCH 'https://theluminary.xyz/api/public/v1/goals' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "id": "g1b2c3d4-...", "progress": 75, "status": "active" }'

Response 200

{
  "ok": true,
  "data": { "id": "g1b2c3d4-...", "progress": 75, "status": "active" }
}

Journal

GET/api/public/v1/journalBearer token

Recent journal entries

The 50 most recent entries, newest first, each with its stored AI analysis.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/journal' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": [
    {
      "id": "j1b2...",
      "title": "Long day",
      "content": "Kept circling the same conversation...",
      "mood": "restless",
      "themes": ["boundaries", "work"],
      "ai_analysis": {
        "summary": "You are naming a boundary you have not yet enforced.",
        "archetypes": ["Saturn in the 10th"],
        "prompt": "What would enforcing it cost you this week?"
      },
      "created_at": "2026-07-28T22:41:00.000Z"
    }
  ]
}
POST/api/public/v1/journalBearer token

Write an entry and get AI analysis

Writes the entry and returns it together with the AI analysis in one call. Counts against the caller's monthly journal quota.

Body

FieldTypeReq.Description
contentstringyesEntry body.
titlestringnoOptional title; one is inferred when omitted.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/journal' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "title": "Long day", "content": "Kept circling the same conversation..." }'

Response 200

{
  "ok": true,
  "data": {
    "entry": {
      "id": "j1b2...",
      "title": "Long day",
      "content": "Kept circling the same conversation...",
      "mood": "restless",
      "themes": ["boundaries", "work"],
      "created_at": "2026-07-28T22:41:00.000Z"
    },
    "analysis": {
      "summary": "You are naming a boundary you have not yet enforced.",
      "archetypes": ["Saturn in the 10th"],
      "prompt": "What would enforcing it cost you this week?"
    }
  }
}

Errors

  • 402 quota_exceeded Monthly journal-analysis allowance is spent.
  • 503 upstream_unavailable The AI gateway is unavailable — the entry is not written; retry.

Relationships

GET/api/public/v1/relationshipsBearer token

Saved relationships

Every relationship the caller has saved. `name` is the person's display name. `synastry_summary` is the stored together-summary when a reading has been generated.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/relationships' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": [
    {
      "id": "r1b2...",
      "name": "Sam",
      "relationship_type": "partner",
      "birth_date": "1990-11-02",
      "birth_time": "14:10",
      "birth_location": "Chicago, Illinois, United States",
      "sun_sign": "Scorpio",
      "moon_sign": "Leo",
      "rising_sign": "Aquarius",
      "synastry_summary": "Fixed-sign gridlock with a generous Venus trine...",
      "created_at": "2026-05-11T16:03:00.000Z"
    }
  ]
}
POST/api/public/v1/relationshipsBearer token

Add a person or generate synastry

Creates a saved person from birth details (Sun always; Moon/Rising when time and location are present) or generates a synastry reading. The iOS app must be able to add people in-app (guideline 2.1 / 4.2) — do not send reviewers to the website. `GET` maps `display_name` to `name`. Generating synastry counts against the synastry quota.

Body

FieldTypeReq.Description
namestringnoTheir name. Required unless `action` is `synastry`.
relationshipType"partner" | "family" | "friend" | "collaborator" | "ex" | "other"noDefaults to `partner`.
birthDatestring (YYYY-MM-DD)noRequired when creating.
birthTimestring (HH:MM)noOptional; needed for Moon/Rising.
birthLocationstringnoOptional; geocoded server-side.
notesstringnoOptional context for synastry.
labelstringnoOptional label, e.g. wife.
actionstringno`synastry` to generate a reading for an existing person.
idstring (uuid)noRelationship id when `action` is `synastry`.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/relationships' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "name": "Sam", "relationshipType": "partner", "birthDate": "1990-11-02", "birthTime": "14:10", "birthLocation": "Chicago, Illinois, United States" }'

Response 200

{
  "ok": true,
  "data": {
    "id": "r1b2...",
    "name": "Sam",
    "relationship_type": "partner",
    "birth_date": "1990-11-02",
    "sun_sign": "Scorpio",
    "moon_sign": "Leo",
    "rising_sign": "Aquarius",
    "synastry_summary": null
  }
}

Errors

  • 400 invalid_request Name or birth date is missing, or the relationship id is not yours.
  • 402 quota_exceeded Monthly synastry allowance is spent.

Breathwork

GET/api/public/v1/breathworkBearer token

Session history

The caller's logged sessions. The technique catalogue itself lives on `/meta` so it can be cached without a token.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/breathwork' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": [
    {
      "id": "b1c2...",
      "technique_slug": "box-breathing",
      "duration_seconds": 300,
      "rounds_completed": 6,
      "mood_before": "anxious",
      "mood_after": "settled",
      "goal": "calm",
      "notes": null,
      "created_at": "2026-07-29T06:50:00.000Z"
    }
  ]
}
POST/api/public/v1/breathworkBearer token

Log a session

Records a completed breathwork session. `techniqueSlug` must be one of the slugs returned by `/meta`.

Body

FieldTypeReq.Description
techniqueSlugstringyesTechnique slug from `/meta`.
durationSecondsnumberyesTotal session length in seconds.
roundsCompletednumbernoCompleted rounds.
moodBeforestringnoMood before the session.
moodAfterstringnoMood after the session.
goalstringnoIntent, e.g. `calm`, `focus`, `sleep`.
notesstringnoFree-text notes.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/breathwork' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "techniqueSlug": "box-breathing", "durationSeconds": 300, "roundsCompleted": 6, "moodBefore": "anxious", "moodAfter": "settled", "goal": "calm" }'

Response 200

{
  "ok": true,
  "data": {
    "id": "b1c2...",
    "technique_slug": "box-breathing",
    "duration_seconds": 300,
    "rounds_completed": 6,
    "created_at": "2026-07-29T06:50:00.000Z"
  }
}

Errors

  • 400 invalid_request Unknown technique slug.

Codex

GET/api/public/v1/codexBearer token

Lessons with the caller's progress

Returns the lesson catalogue and the caller's progress rows in one call; join them client-side on `lesson_id`.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/codex' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": {
    "lessons": [
      {
        "id": "l1b2...",
        "slug": "the-luminaries",
        "title": "The Luminaries",
        "section": "foundations",
        "summary": "Sun, Moon, and why the pair matters more than either alone.",
        "order_index": 1
      }
    ],
    "progress": [
      {
        "lesson_id": "l1b2...",
        "status": "complete",
        "reflection": "The Moon framing landed.",
        "updated_at": "2026-07-20T12:00:00.000Z"
      }
    ]
  }
}
POST/api/public/v1/codexBearer token

Record lesson progress

Upserts the caller's progress on one lesson.

Body

FieldTypeReq.Description
lessonIdstring (uuid)yesLesson id from `GET /codex`.
status"in_progress" | "complete"yesNew progress status.
reflectionstringnoThe user's reflection on the lesson.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/codex' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "lessonId": "l1b2...", "status": "complete", "reflection": "The Moon framing landed." }'

Response 200

{
  "ok": true,
  "data": { "lesson_id": "l1b2...", "status": "complete", "updated_at": "2026-07-29T09:00:00.000Z" }
}

Chat

GET/api/public/v1/chat/threadsBearer token

List threads

The caller's chat threads, most recently updated first.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/chat/threads' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": [
    { "id": "t1b2...", "title": "Career timing", "created_at": "2026-07-01T10:00:00.000Z", "updated_at": "2026-07-28T21:00:00.000Z" }
  ]
}
POST/api/public/v1/chat/threadsBearer token

Create a thread

Creates an empty thread. A title is generated from the first message when omitted.

Body

FieldTypeReq.Description
titlestringnoOptional thread title.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/chat/threads' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "title": "Career timing" }'

Response 200

{
  "ok": true,
  "data": { "id": "t1b2...", "title": "Career timing", "created_at": "2026-07-29T09:10:00.000Z" }
}
PATCH/api/public/v1/chat/threadsBearer token

Rename a thread

The thread id travels in the body.

Body

FieldTypeReq.Description
idstring (uuid)yesThread id.
titlestringyesNew title.

Request

curl -X PATCH 'https://theluminary.xyz/api/public/v1/chat/threads' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "id": "t1b2...", "title": "Saturn return plan" }'

Response 200

{ "ok": true, "data": { "id": "t1b2...", "title": "Saturn return plan" } }
DELETE/api/public/v1/chat/threadsBearer token

Delete a thread

Deletes the thread and all of its messages. The id travels in the body.

Body

FieldTypeReq.Description
idstring (uuid)yesThread id.

Request

curl -X DELETE 'https://theluminary.xyz/api/public/v1/chat/threads' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "id": "t1b2..." }'

Response 200

{ "ok": true, "data": { "id": "t1b2...", "deleted": true } }
GET/api/public/v1/chat/messagesBearer token

Read a thread

All messages in one thread, oldest first.

Query parameters

FieldTypeReq.Description
threadIdstring (uuid)yesThread to read.

Request

curl -X GET 'https://theluminary.xyz/api/public/v1/chat/messages?threadId=<threadId>' \
  -H 'Authorization: Bearer $ACCESS_TOKEN'

Response 200

{
  "ok": true,
  "data": [
    { "id": "m1...", "role": "user", "content": "When should I make the move?", "created_at": "2026-07-28T20:59:00.000Z" },
    { "id": "m2...", "role": "assistant", "content": "Your progressed Moon...", "created_at": "2026-07-28T21:00:00.000Z" }
  ]
}

Errors

  • 400 invalid_request `threadId` is missing.
POST/api/public/v1/chat/messagesBearer token

Send a message

Non-streaming send: writes the user message, runs the Codex-grounded model, stores and returns the reply. Counts against the caller's monthly chat quota. Expect multi-second latency — use a generous client timeout (60s) and an optimistic UI.

Body

FieldTypeReq.Description
threadIdstring (uuid)yesTarget thread.
messagestringyesThe user's message.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/chat/messages' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "threadId": "t1b2...", "message": "When should I make the move?" }'

Response 200

{
  "ok": true,
  "data": {
    "userMessage": { "id": "m1...", "role": "user", "content": "When should I make the move?" },
    "assistantMessage": { "id": "m2...", "role": "assistant", "content": "Your progressed Moon..." }
  }
}

Errors

  • 402 quota_exceeded Monthly chat allowance is spent.
  • 400 invalid_request The thread does not exist or belongs to another user.
  • 503 upstream_unavailable The AI gateway failed. Safe to retry.

Billing

POST/api/public/v1/billing/apple/notificationsPublic

App Store Server Notifications V2

Apple POSTs a `signedPayload` JWS on renew, expire, refund, and revoke. Paste `https://theluminary.xyz/api/public/v1/billing/apple/notifications` into App Store Connect → App Information → App Store Server Notifications (Production and Sandbox). `REFUND` / `REVOKE` / `EXPIRED` mark the matching `source=apple` row canceled/expired and drop the user to Nebula when no other active plan remains. Not called by the iOS app.

Body

FieldTypeReq.Description
signedPayloadstringyesApple-signed notification JWS (ResponseBodyV2). Inner `data.signedTransactionInfo` is a StoreKit 2 transaction JWS.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/billing/apple/notifications' \
  -H 'Content-Type: application/json' \
  -d '{ "signedPayload": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyIuLi4iXX0.eyJub3RpZmljYXRpb25UeXBlIjoiVEVTVCJ9.sig" }'

Response 200

{
  "ok": true,
  "data": { "received": true, "type": "REFUND", "action": "revoked", "originalTransactionId": "1000000123456789" }
}

Errors

  • 400 invalid_request Body is not a signedPayload JWS, or bundleId / productId do not match Luminary.
POST/api/public/v1/billing/appleBearer token

Verify StoreKit 2 JWS

iOS In-App Purchase verification. Send a StoreKit 2 signed transaction JWS. The server checks bundle id, product id, and (on Vercel production) the x5c signature rooted at Apple Root CA - G3 with Apple receipt-signing OIDs, then writes `public.subscriptions` with `source=apple` and updates `profiles.tier`. Never send a client-chosen tier. Restore with `{ "action": "restore", "signedTransactions": ["..."] }`. iOS must not call `/billing/checkout` (guideline 3.1.1).

Body

FieldTypeReq.Description
signedTransactionstringnoStoreKit 2 JWS for a single purchase. Required unless restoring.
actionstringno`restore` to verify a list of transactions and apply the latest.
signedTransactionsstring[]noJWS list when `action` is `restore`.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/billing/apple' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "signedTransaction": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyIuLi4iXX0.eyJidW5kbGVJZCI6Inh5ei50aGVsdW1pbmFyeS5hcHAifQ.sig" }'

Response 200

{
  "ok": true,
  "data": {
    "tier": "constellation",
    "originalTransactionId": "1000000123456789",
    "expiresAt": "2026-09-25T16:00:00.000Z"
  }
}

Errors

  • 400 invalid_request JWS is missing, forged, or the wrong bundle/product.
  • 429 invalid_request More than 20 IAP verifies per minute from this IP.
POST/api/public/v1/billing/checkoutBearer token

Stripe hosted checkout (web/Android)

Creates a Stripe Checkout Session and returns a hosted `url`. Send `X-Client-Platform: ios` and the API rejects the call — iOS digital subscriptions must use StoreKit and `POST /billing/apple`.

Body

FieldTypeReq.Description
tierIdconstellation | cosmosnoPaid plan. Alternative to `priceId`.
priceIdstringnoStripe Price lookup_key (e.g. constellation_monthly).
successUrlstring (url)yesReturn URL after payment.
cancelUrlstring (url)yesReturn URL if the user backs out.
environmentsandbox | liveyesStripe mode.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/billing/checkout' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "tierId": "constellation", "successUrl": "https://theluminary.xyz/billing/return", "cancelUrl": "https://theluminary.xyz/billing", "environment": "live" }'

Response 200

{ "ok": true, "data": { "url": "https://checkout.stripe.com/c/pay/cs_..." } }

Errors

  • 400 invalid_request iOS client, missing URLs, or Stripe has no matching price.
POST/api/public/v1/billing/portalBearer token

Stripe customer portal

Opens the Stripe customer portal for a web/Android subscription. Allowed on iOS for users who originally subscribed on the web (reader-style manage). Apple IAP subscribers manage in Settings → Subscriptions.

Body

FieldTypeReq.Description
returnUrlstring (url)noWhere Stripe should send the user after the portal.
environmentsandbox | liveyesStripe mode.

Request

curl -X POST 'https://theluminary.xyz/api/public/v1/billing/portal' \
  -H 'Authorization: Bearer $ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "returnUrl": "https://theluminary.xyz/billing", "environment": "live" }'

Response 200

{ "ok": true, "data": { "url": "https://billing.stripe.com/p/session/..." } }

Errors

  • 400 invalid_request No Stripe customer on file for this account.