Goal

Carry a plan selection across the boundary between the landing (visitor, not yet logged in) and the dashboard (authenticated user about to be redirected to Stripe Checkout). The endpoint signs an HMAC-SHA256 payload (plan_slug, currency, billing_interval, exp) and stores it as the app_checkout_intent cookie. After the user verifies their phone via OTP, RegistrationController reads the cookie back, validates the signature + TTL, and creates the Checkout Session automatically.

This is the only /v1/users/me/* endpoint that does not require authentication — the visitor has not signed up yet at the moment they pick a plan.

Prerequisites

  • A plan slug from GET /v1/plans/public (e.g. pro, starter).
  • A site origin allowed to set first-party cookies under .example.com (or whichever root domain serves your landing).

Steps

1. Sign the intent

curl -X POST 'https://api.example.com/v1/users/me/subscription/checkout-intent' \
  -H 'Content-Type: application/json' \
  -c cookies.txt \
  -d '{
    "plan_slug": "pro",
    "currency": "MXN",
    "billing_interval": "month"
  }'

The response sets the app_checkout_intent cookie:

Set-Cookie: app_checkout_intent=<payload>.<hmac>;
            Domain=.example.com;
            Max-Age=1800;
            HttpOnly; SameSite=Lax; Secure

Body (200):

{
  "data": {
    "type": "checkout_intent",
    "attributes": {
      "plan_slug": "pro",
      "currency": "MXN",
      "billing_interval": "month",
      "expires_at": "2026-05-28T11:30:00Z"
    }
  }
}

2. Continue the registration flow

After signing the intent, redirect the visitor to /register. Once they verify their phone via OTP, the post-OTP step reads the cookie, validates the HMAC + TTL, and starts the Stripe Checkout Session without any extra round-trip.

The cookie is cleared on consume — if the user re-enters the flow, ask them to pick a plan again.

Stripe.js integration

You typically do not call confirmCardPayment from this step. This endpoint only signs the intent; the actual Stripe Checkout Session is created server-side inside the registration verify-OTP handler, which then redirects to session.url. The landing's only job is to call this endpoint when the visitor clicks "Choose Pro / Choose Starter" and let the redirect chain do the rest.

Errors

StatusCodeCause
422billing_plan_slug_invalidplan_slug is missing or fails the [a-z0-9_-]{1,50} pattern.
422billing_intent_invalid_shapecurrency or billing_interval outside the allowed enum (MXN/USD, month/year).
429rate_limitedToo many intent-sign requests from the same IP. Retry after the Retry-After header.

Notes

  • The endpoint does not validate that the plan exists in the database. A phantom slug is signed normally; the post-OTP flow fails silently (no Stripe redirect) and the user lands on the dashboard. Validate slugs against GET /v1/plans/public client-side before posting if you want a sharper UX.
  • TTL is 30 minutes (exp = now + 1800). Replay outside that window returns 400 intent_expired at the post-OTP step.
  • In production the cookie carries Secure. In local dev it does not, so testing against http://localhost works without TLS.
  • See ADR-0024 (Stripe Billing Architecture) for the broader Stripe flow and ADR-0090 (URL Convention) for the rationale behind the /v1/users/me/subscription/checkout-intent path.

New in v1.49.6 — see ADR-0090.