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; SecureBody (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
| Status | Code | Cause |
|---|---|---|
| 422 | billing_plan_slug_invalid | plan_slug is missing or fails the [a-z0-9_-]{1,50} pattern. |
| 422 | billing_intent_invalid_shape | currency or billing_interval outside the allowed enum (MXN/USD, month/year). |
| 429 | rate_limited | Too 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/publicclient-side before posting if you want a sharper UX. - TTL is 30 minutes (
exp = now + 1800). Replay outside that window returns400 intent_expiredat the post-OTP step. - In production the cookie carries
Secure. In local dev it does not, so testing againsthttp://localhostworks 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-intentpath.
New in v1.49.6 — see ADR-0090.