Skip to content

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:

  1. Create the user in Paygent with POST /users — registers them once, links your external_user_id to Paygent
  2. 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, both period_start and period_end must 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_period is 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_start and invoice.period_end (Unix timestamps — convert to ISO 8601 before sending).
  • LemonSqueezy: current_period_start and current_period_end on the subscription object.
  • Paddle: current_billing_period.starts_at and current_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_cost carries forward via max(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