Assign Users to Plans
Register your end users with Paygent and assign them to the plans you've configured.
Once your plans exist, you need to tell Paygent which user is on which plan. This is the bridge from your existing auth and checkout flow to Paygent's enforcement layer.
The two-step flow
For every end user of your product:
- Create the user in Paygent with
POST /users— registers them once, links yourexternal_user_idto Paygent - Subscribe them to a plan with
POST /users/{user_id}/subscription— assigns the plan and (when needed) the billing window
Most teams call step 1 from their signup handler and step 2 from their checkout-success handler. You can also run both at signup if every user starts on a free plan.
User IDs are yours
Paygent's external_user_id is whatever ID your application already uses for this user. It's an opaque string — Paygent doesn't validate format, just uniqueness within your product. Use the same value here that you'll later pass to paygent_context(user_id=...).
Your app Paygent
-------- -------
user.id = "user_123" ──────► external_user_id = "user_123"
paygent_user_id = "550e8400-..." (Paygent's internal UUID)
You only ever pass external_user_id. Paygent's internal UUID is used on the backend for foreign keys but you don't have to think about it.
Step 1: Create the user
Register them once. If they already exist, the API returns 409 — treat it as idempotent in your signup handler.
curl
curl -X POST https://api.paygent.to/api/v1/users \
-H "Authorization: Bearer pg_live_..." \
-H "Content-Type: application/json" \
-d '{
"external_user_id": "user_123",
"name": "Alice Customer"
}'
Python (httpx)
import httpx
r = httpx.post(
"https://api.paygent.to/api/v1/users",
headers={"Authorization": "Bearer pg_live_..."},
json={
"external_user_id": "user_123",
"name": "Alice Customer",
},
)
# Treat "already exists" as success — your signup handler is now idempotent
if r.status_code not in (201, 409):
r.raise_for_status()
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
external_user_id |
string (1–255) | Yes | Your stable user identifier. The SDK passes this as user_id. |
name |
string | null | No | Display name. Used in support and dashboards. |
Response (201)
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"external_user_id": "user_123",
"name": "Alice Customer",
"plan_name": null,
"stripe_subscription_id": null,
"created_at": "2026-05-09T12:00:00Z",
"updated_at": "2026-05-09T12:00:00Z"
}
plan_name: null — the user has no plan yet. A user without a plan triggers permissive defaults in the SDK (no limits, every call passes the guard). Always run Step 2 before letting the user make their first LLM call in production.
Step 2: Subscribe them to a plan
This is the call you run after your own checkout flow succeeds — Stripe, LemonSqueezy, whatever you use. user_id in the URL is the external_user_id from Step 1.
curl
curl -X POST https://api.paygent.to/api/v1/users/user_123/subscription \
-H "Authorization: Bearer pg_live_..." \
-H "Content-Type: application/json" \
-d '{
"plan_id": "PRO_PLAN_UUID",
"stripe_subscription_id": "sub_1OabCDeFghIJ",
"period_start": "2026-05-01T00:00:00Z",
"period_end": "2026-06-01T00:00:00Z"
}'
Python (httpx)
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
httpx.post(
"https://api.paygent.to/api/v1/users/user_123/subscription",
headers={"Authorization": "Bearer pg_live_..."},
json={
"plan_id": pro_plan_id, # UUID from POST /config/plans
"stripe_subscription_id": "sub_1OabCDeFghIJ",
"period_start": now.isoformat(),
"period_end": (now + timedelta(days=30)).isoformat(),
},
).raise_for_status()
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
plan_id |
UUID | Yes | The plan UUID returned by POST /config/plans. Plan names won't work here. |
stripe_subscription_id |
string | null | No | Your Stripe sub ID, stored for cross-reference. Paygent never calls Stripe. |
period_start |
ISO datetime | null | Conditional | Start of the billing window. |
period_end |
ISO datetime | null | Conditional | End of the billing window (exclusive). |
Period dates are required when the plan has a finite spend cap.
If the plan you're assigning has a finite
max_spend_per_period, bothperiod_startandperiod_endmust be supplied. Without them, Paygent has no window over which to enforce the cap. The endpoint returns 400 Bad Request.For unlimited plans (
max_spend_per_periodis null), period dates are optional but recommended for analytics.
Where the dates come from
In production, source the dates from your payment processor — they line up exactly with the customer's billing cycle:
- Stripe:
invoice.period_startandinvoice.period_end(Unix timestamps — convert to ISO 8601 before sending). - LemonSqueezy:
current_period_startandcurrent_period_endon the subscription object. - Paddle:
current_billing_period.starts_atandcurrent_billing_period.ends_at.
For dev / testing, use now and now + 30 days as in the example above.
Response (200)
Returns the updated UserResponse with plan_name populated:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"external_user_id": "user_123",
"name": "Alice Customer",
"plan_name": "pro",
"stripe_subscription_id": "sub_1OabCDeFghIJ",
"created_at": "2026-05-09T12:00:00Z",
"updated_at": "2026-05-09T14:30:00Z"
}
Possible errors
| Status | When |
|---|---|
400 Bad Request |
Period dates missing on a plan that requires them, or period_end <= period_start. |
404 Not Found |
User doesn't exist (run Step 1 first), or plan_id doesn't exist under your product. |
The full flow
This is what a real checkout-to-Paygent integration looks like end-to-end:
┌──────────────────────────────────────────────────┐
│ 1. User clicks "Upgrade to Pro" in your app │
│ 2. Your app starts Stripe checkout │
│ 3. Stripe completes payment │
│ 4. Stripe webhook hits your server │
│ 5. Your server calls Paygent: │
│ POST /users/user_123/subscription │
│ { plan_id: pro_id, │
│ period_start: invoice.period_start, │
│ period_end: invoice.period_end } │
│ 6. (Optional) pg.refresh_user("user_123") │
│ 7. Next LLM call uses Pro limits │
└──────────────────────────────────────────────────┘
Step 6 is optional — the SDK auto-refreshes cached user state every refresh_interval seconds (default 60). Calling pg.refresh_user() makes the change effective immediately rather than waiting for the next tick.
# After updating subscription via the API:
pg.refresh_user("user_123")
Mid-period upgrades and downgrades
When a user changes plans mid-period, the period dates you send determine whether their existing usage carries over:
- Same period dates — existing
period_costcarries forward viamax(local, backend)merge. A user who already spent $4.20 on Free now has $4.20 spent against the new Pro $49 cap. - Fresh period dates (e.g.,
period_start = now()) — SDK detects period rollover, zeroes local counters, syncs to backend's new-period values (typically $0).
Pick the behavior you want by choosing the dates you send. There's no separate "reset balance" flag.
Downgrades and cancellations
To cancel a subscription, assign the user to a free plan or a cancelled plan with max_spend_per_period: 0.00. The hard gate will block every call from then on.
httpx.post(
"https://api.paygent.to/api/v1/users/user_123/subscription",
headers={"Authorization": "Bearer pg_live_..."},
json={
"plan_id": free_plan_id,
"period_start": now.isoformat(),
"period_end": (now + timedelta(days=30)).isoformat(),
},
).raise_for_status()
pg.refresh_user("user_123")
There's no explicit "remove user from plan" endpoint — every user must always be on some plan (or fall through to permissive defaults, which is unsafe for production).
What's next
- Cost Guardrails — understand the gate, soft/hard thresholds, and pre-flight check methods
- Verify it's working — confirm every step is wired correctly before shipping
- Backend API reference → Users — full request and response schemas for every user endpoint