API documentation

Full reference for the Subscription Portal API. Copy as markdown to paste into a prompt or README.

Subscription Portal API

The Subscription Portal is the single source of truth for protected slugs, paywall copy, subscription tiers, Stripe plans, subscribers and entitlement checks. This document describes every public HTTP endpoint exposed by the portal, the authentication model, the response shapes, caching behaviour and recommended integration patterns for a Next.js consumer.

  • Base URL (production): https://billing.patient.info
  • Content type: application/json (UTF-8)
  • Time format: ISO 8601 / RFC 3339 (UTC)
  • Money format: integer minor units (e.g. pence) plus an ISO-4217 currency (default gbp)

1. Authentication

There are three distinct credentials. Use the right one for each endpoint family.

CallerEndpointsCredentialHeader
Next.js server (build / ISR / route handler)/api/public/* (excluding webhooks)API keyx-api-key: sk_live_…
End user (browser or Next.js server acting on their behalf)/api/entitlements/me, /api/checkout, /api/portalClerk session JWTAuthorization: Bearer <jwt>
Stripe/api/public/webhooks/stripeStripe signatureStripe-Signature: t=…,v1=…

1.1 API keys

  • Created in Dashboard → API keys. Only the prefix is stored after creation; the full key is shown once.
  • Hashed at rest (SHA-256). Last-used timestamp is recorded on each successful call.
  • Revoke at any time from the dashboard; revoked keys return 401.
  • Keys are environment-agnostic; rotate per environment by issuing one key per consumer.

1.2 Clerk JWTs

  • The portal verifies tokens against your Clerk instance via JWKS (CLERK_JWKS_URL) and checks the iss claim against CLERK_ISSUER.
  • Use the same Clerk app that powers the Next.js consumer — no extra configuration is required.
  • The sub claim is the Clerk user id (e.g. user_2abc…) and is used as the subscriber identity.

1.3 Errors

All errors return JSON { "error": "<message>" } with the appropriate status:

StatusMeaning
400Validation error (missing/invalid body)
401Missing or invalid credential
403Credential valid but not permitted
404Resource not found (tier, subscriber, customer)
409Idempotency conflict (webhooks only)
5xxUnhandled server error — safe to retry with backoff

2. Public endpoints (server-to-server, cacheable)

Designed for high-traffic anonymous pages. They return the same payload for every caller, support ETag / 304 revalidation and Cache-Control: public, max-age=60, stale-while-revalidate=600. The version field is a monotonic timestamp updated whenever any slug, tier or paywall config changes — use it as a cache key.

2.1 GET /api/public/protected-slugs

Returns every active protected-slug rule, plus per-rule paywall copy and the tier required to unlock it.

Request

GET /api/public/protected-slugs HTTP/1.1
Host: billing.patient.info
x-api-key: sk_live_…
If-None-Match: "f3a1…"

Response 200

{
  "version": "2026-06-27T10:00:00.000Z",
  "rules": [
    {
      "slug": "/professional/*",
      "match": "wildcard",
      "requiredTier": "professional",
      "contentType": "article",
      "title": "Professional clinical content",
      "previewMode": "paragraphs",
      "previewParagraphs": 2,
      "customTeaser": null,
      "paywallSeo": true,
      "paywall": {
        "headline": "Unlock professional guidance",
        "body": "Subscribe to access full clinical articles…",
        "cta": "Start free trial",
        "signin": "Already a subscriber? Sign in",
        "subscribe": "Pick a plan"
      },
      "updatedAt": "2026-06-20T09:11:22.000Z"
    }
  ]
}

Response 304 — body empty, same ETag, no quota impact.

Field reference

FieldTypeNotes
slugstringPath or pattern. Always starts with /.
match"exact" | "wildcard"wildcard supports trailing * (e.g. /pro/*).
requiredTierstring | nullTier slug required to read. null = login-only.
contentTypestring | nullFree-form label used in analytics / SEO.
previewMode"none" | "paragraphs" | "custom"How much of the body to render anonymously.
previewParagraphsnumber | nullUsed when previewMode = "paragraphs".
customTeaserstring | nullHTML teaser when previewMode = "custom".
paywallSeobooleanIf true, emit Google paywalled-content JSON-LD.
paywall.*stringsPer-rule copy; falls back to global defaults when null.

2.2 GET /api/public/paywall-config

Returns global brand strings, default paywall copy and the list of active tiers. Use this to render pricing pages, paywall fallbacks and tier marketing without round-tripping to Stripe.

Response 200

{
  "version": "2026-06-27T10:00:00.000Z",
  "brand": { "name": "Patient", "supportEmail": "support@patient.info" },
  "defaults": {
    "headline": "Subscribe to keep reading",
    "body": "Patient Pro unlocks the full clinical library.",
    "cta": "See plans",
    "signinPrompt": "Already subscribed? Sign in.",
    "subscribePrompt": "Not a subscriber yet?"
  },
  "tierCopy": {
    "professional": { "tagline": "For clinicians", "badge": "Most popular" }
  },
  "tiers": [
    {
      "id": "uuid",
      "slug": "professional",
      "name": "Professional",
      "description": "Full access for clinicians",
      "features": ["Full articles", "CPD tracking", "Priority support"],
      "monthly_price_cents": 1499,
      "annual_price_cents": 14990,
      "currency": "gbp",
      "status": "active",
      "trial_eligible": true,
      "trial_days": 14,
      "sort_order": 1
    }
  ]
}

3. User endpoints (Clerk JWT required)

Per-user, never cached (Cache-Control: private, no-store). Call from a Next.js Route Handler or directly from the browser using the Clerk session token.

3.1 GET /api/entitlements/me

Returns the signed-in user's subscriber record, active subscription (if any), the resolved tier object and the list of slug patterns they may access. Creates the subscriber record on first call.

Response 200

{
  "user": {
    "clerkUserId": "user_2abc…",
    "email": "jane@example.com",
    "subscriberId": "uuid"
  },
  "subscription": {
    "status": "active",
    "currentPeriodEnd": "2026-07-27T10:00:00.000Z",
    "cancelAtPeriodEnd": false,
    "paymentFailedAt": null,
    "trialEnd": null
  },
  "tier": { "id": "uuid", "slug": "professional", "name": "Professional", "features": ["..."] },
  "features": ["Full articles", "CPD tracking"],
  "allowedSlugs": ["/professional/*", "/health/*"]
}

subscription and tier are null when the user has never subscribed. subscription.status is one of active, trialing, past_due, canceled, incomplete, incomplete_expired, unpaid, paused.

Gating recipe

const ent = await fetch(`${PORTAL}/api/entitlements/me`, {
  headers: { Authorization: `Bearer ${await getToken()}` },
}).then((r) => r.json());

const allowed =
  ent.subscription?.status === "active" || ent.subscription?.status === "trialing";

3.2 POST /api/checkout

Creates a Stripe Checkout Session. Idempotently creates a Stripe customer for the user on first use.

Request body

FieldTypeRequiredNotes
tierSlugstringyesMatches subscription_tiers.slug.
interval"monthly" | "annual"yesMust have a Stripe price configured on the tier.
successUrlstringnoDefaults to {origin}/billing/success?session_id={CHECKOUT_SESSION_ID}.
cancelUrlstringnoDefaults to {origin}/billing/cancel.
couponstringnoStripe coupon id. allow_promotion_codes is always true.

Response 200

{ "url": "https://checkout.stripe.com/c/pay/cs_test_…", "sessionId": "cs_test_…" }

Redirect the browser to url. If the tier has trial_eligible and trial_days, a trial is attached automatically.

3.3 POST /api/portal

Creates a Stripe Customer Portal session so the user can manage payment methods, invoices and cancellation.

Request body

{ "returnUrl": "https://patient.info/account" }

Response 200

{ "url": "https://billing.stripe.com/p/session/…" }

Returns 404 if the user has never been to checkout (no Stripe customer).


4. Stripe webhook

POST /api/public/webhooks/stripe

  • Configure once in Stripe → Developers → Webhooks, pointing at the URL above.
  • Signature verified with STRIPE_WEBHOOK_SECRET. Invalid signatures return 400.
  • Idempotent by Stripe event.id (stored in webhook_events). Duplicate deliveries return 200 without reprocessing.
  • Bumps config_version on tier/price changes so public endpoints invalidate immediately.

Required event subscriptions

Checkout — checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed

Subscription lifecycle — customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, customer.subscription.paused, customer.subscription.resumed, customer.subscription.trial_will_end

Invoices — invoice.paid, invoice.payment_succeeded, invoice.payment_failed, invoice.finalized, invoice.upcoming

Customer — customer.created, customer.updated, customer.deleted

Catalog sync — product.created, product.updated, product.deleted, price.created, price.updated, price.deleted


5. Caching & invalidation model

  1. Build / ISR fetches /api/public/protected-slugs and /api/public/paywall-config and stores the response keyed by version.
  2. Each subsequent fetch sends If-None-Match: <etag>. A 304 keeps the cached payload alive.
  3. The portal bumps config_version on every admin write and on relevant Stripe webhooks. Set Next.js revalidate to 60s (matches Cache-Control: max-age=60) and you'll converge within a minute.
  4. Anonymous pages render purely from cached config — no per-request portal call.
  5. Authenticated pages call /api/entitlements/me once per request (or once per session in the browser) to gate full content.

5.1 Recommended Next.js fetcher

// lib/portal.ts
const PORTAL = process.env.PORTAL_URL!;
const KEY = process.env.PORTAL_API_KEY!;

export async function getProtectedSlugs() {
  const res = await fetch(`${PORTAL}/api/public/protected-slugs`, {
    headers: { "x-api-key": KEY },
    next: { revalidate: 60, tags: ["portal-slugs"] },
  });
  if (!res.ok) throw new Error(`Portal ${res.status}`);
  return res.json() as Promise<{ version: string; rules: SlugRule[] }>;
}

Call revalidateTag("portal-slugs") from a webhook receiver in the Next.js app if you want sub-minute invalidation.


6. SEO: Google paywalled-content JSON-LD

For rules where paywallSeo = true, wrap the gated HTML in <div class="paywalled-content">…</div> and emit the following JSON-LD inside the article <head>:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "NewsArticle",
  "headline": "…",
  "isAccessibleForFree": "False",
  "hasPart": {
    "@type": "WebPageElement",
    "isAccessibleForFree": "False",
    "cssSelector": ".paywalled-content"
  }
}
</script>

Google then indexes the full body without penalising you for cloaking, as long as the preview is also visible to anonymous users.


7. Integration checklist

  1. Secrets: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, CLERK_JWKS_URL, CLERK_ISSUER (and BREVO_API_KEY if you use Brevo).
  2. Bootstrap admin: sign in at /, click Claim admin on the dashboard (only works while no admins exist). Add further admins by inserting into user_roles.
  3. Tiers: create in Dashboard → Tiers & plans. Saving pushes to Stripe automatically; "Resync from Stripe" pulls manual changes back.
  4. Slugs: add patterns under Dashboard → Protected slugs. Wildcards end with *.
  5. API keys: create one per consumer in Dashboard → API keys. Copy the full key once.
  6. Webhook: point Stripe at /api/public/webhooks/stripe and enable the events in §4.
  7. Next.js: store PORTAL_URL and PORTAL_API_KEY in env, fetch the two public endpoints at build / ISR, call user endpoints with the Clerk JWT.

8. Status codes summary

Endpoint200304400401404
GET /api/public/protected-slugsinvalid api key
GET /api/public/paywall-configinvalid api key
GET /api/entitlements/meinvalid jwt
POST /api/checkoutmissing fieldsinvalid jwtunknown tier
POST /api/portalinvalid jwtno stripe customer
POST /api/public/webhooks/stripebad signature