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-4217currency(defaultgbp)
1. Authentication
There are three distinct credentials. Use the right one for each endpoint family.
| Caller | Endpoints | Credential | Header |
|---|---|---|---|
| Next.js server (build / ISR / route handler) | /api/public/* (excluding webhooks) | API key | x-api-key: sk_live_… |
| End user (browser or Next.js server acting on their behalf) | /api/entitlements/me, /api/checkout, /api/portal | Clerk session JWT | Authorization: Bearer <jwt> |
| Stripe | /api/public/webhooks/stripe | Stripe signature | Stripe-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 theissclaim againstCLERK_ISSUER. - Use the same Clerk app that powers the Next.js consumer — no extra configuration is required.
- The
subclaim 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:
| Status | Meaning |
|---|---|
| 400 | Validation error (missing/invalid body) |
| 401 | Missing or invalid credential |
| 403 | Credential valid but not permitted |
| 404 | Resource not found (tier, subscriber, customer) |
| 409 | Idempotency conflict (webhooks only) |
| 5xx | Unhandled 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
| Field | Type | Notes |
|---|---|---|
slug | string | Path or pattern. Always starts with /. |
match | "exact" | "wildcard" | wildcard supports trailing * (e.g. /pro/*). |
requiredTier | string | null | Tier slug required to read. null = login-only. |
contentType | string | null | Free-form label used in analytics / SEO. |
previewMode | "none" | "paragraphs" | "custom" | How much of the body to render anonymously. |
previewParagraphs | number | null | Used when previewMode = "paragraphs". |
customTeaser | string | null | HTML teaser when previewMode = "custom". |
paywallSeo | boolean | If true, emit Google paywalled-content JSON-LD. |
paywall.* | strings | Per-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
| Field | Type | Required | Notes |
|---|---|---|---|
tierSlug | string | yes | Matches subscription_tiers.slug. |
interval | "monthly" | "annual" | yes | Must have a Stripe price configured on the tier. |
successUrl | string | no | Defaults to {origin}/billing/success?session_id={CHECKOUT_SESSION_ID}. |
cancelUrl | string | no | Defaults to {origin}/billing/cancel. |
coupon | string | no | Stripe 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 return400. - Idempotent by Stripe
event.id(stored inwebhook_events). Duplicate deliveries return200without reprocessing. - Bumps
config_versionon 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
- Build / ISR fetches
/api/public/protected-slugsand/api/public/paywall-configand stores the response keyed byversion. - Each subsequent fetch sends
If-None-Match: <etag>. A304keeps the cached payload alive. - The portal bumps
config_versionon every admin write and on relevant Stripe webhooks. Set Next.jsrevalidateto 60s (matchesCache-Control: max-age=60) and you'll converge within a minute. - Anonymous pages render purely from cached config — no per-request portal call.
- Authenticated pages call
/api/entitlements/meonce 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
- Secrets:
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,CLERK_JWKS_URL,CLERK_ISSUER(andBREVO_API_KEYif you use Brevo). - Bootstrap admin: sign in at
/, click Claim admin on the dashboard (only works while no admins exist). Add further admins by inserting intouser_roles. - Tiers: create in Dashboard → Tiers & plans. Saving pushes to Stripe automatically; "Resync from Stripe" pulls manual changes back.
- Slugs: add patterns under Dashboard → Protected slugs. Wildcards end with
*. - API keys: create one per consumer in Dashboard → API keys. Copy the full key once.
- Webhook: point Stripe at
/api/public/webhooks/stripeand enable the events in §4. - Next.js: store
PORTAL_URLandPORTAL_API_KEYin env, fetch the two public endpoints at build / ISR, call user endpoints with the Clerk JWT.
8. Status codes summary
| Endpoint | 200 | 304 | 400 | 401 | 404 |
|---|---|---|---|---|---|
GET /api/public/protected-slugs | ✓ | ✓ | — | invalid api key | — |
GET /api/public/paywall-config | ✓ | ✓ | — | invalid api key | — |
GET /api/entitlements/me | ✓ | — | — | invalid jwt | — |
POST /api/checkout | ✓ | — | missing fields | invalid jwt | unknown tier |
POST /api/portal | ✓ | — | — | invalid jwt | no stripe customer |
POST /api/public/webhooks/stripe | ✓ | — | bad signature | — | — |