Skip to content

Backend API Reference

The Paygent backend exposes a small REST API. The SDK uses it. You also call it directly to manage plans, users, and subscriptions.

Base URL: https://api.paygent.to/api/v1

Authentication: Bearer token in Authorization: Bearer pg_live_... header. The product creation endpoint (POST /products) is the only exception — it's how you mint the first key.

Content type: application/json for all request and response bodies.


Products

POST /products

Create a new product (tenant). Returns the API key — exactly once. No authentication required.

import httpx

r = httpx.post(
    "https://api.paygent.to/api/v1/products",
    json={
        "name": "MyAgent",
        "contact_name": "Jane Developer",
        "contact_email": "jane@example.com",
    },
)
r.raise_for_status()
print(r.json())
curl -X POST https://api.paygent.to/api/v1/products \
  -H "Content-Type: application/json" \
  -d '{
    "name": "MyAgent",
    "contact_name": "Jane Developer",
    "contact_email": "jane@example.com"
  }'
// Coming soon

Request body:

{
  "name": "MyAgent",
  "contact_name": "Jane Developer",
  "contact_email": "jane@example.com"
}
Field Type Required
name string (1–255) yes
contact_name string | null no
contact_email string | null no

Response (201):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "MyAgent",
  "slug": "myagent",
  "contact_name": "Jane Developer",
  "contact_email": "jane@example.com",
  "created_at": "2026-05-06T12:00:00Z",
  "api_key": "pg_live_AbCd1234..."
}

api_key is returned only on creation. Store it before discarding the response.


GET /products

List products for the authenticated key.

r = httpx.get(
    "https://api.paygent.to/api/v1/products",
    headers={"Authorization": f"Bearer {api_key}"},
)
print(r.json())
curl https://api.paygent.to/api/v1/products \
  -H "Authorization: Bearer pg_live_..."
// Coming soon

Response (200):

[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "MyAgent",
    "slug": "myagent",
    "contact_name": "Jane Developer",
    "contact_email": "jane@example.com",
    "created_at": "2026-05-06T12:00:00Z"
  }
]

API keys are not included.


Config

POST /config/plans

Create plan configurations.

body = {
    "soft_gate_at": 0.80,
    "hard_gate_at": 1.00,
    "plans": [
        {
            "name": "pro",
            "max_spend_per_period": 49.00,
            "max_spend_per_session": 5.00,
            "session_timeout_minutes": 30.0,
            "model_limits": {
                "gpt-4o": {"max_tokens_per_period": 50000},
            },
            "cost_rates": {
                "gpt-4o": {"input": 0.0025, "output": 0.010},
            },
            "default_cost_rate": {"input": 0.001, "output": 0.003},
            "tool_costs": {"search": 0.05},
            "default_tool_cost": 0.02,
            "pre_call_estimate": False,
            "pre_call_buffer_tokens": 4096,
        },
    ],
}
r = httpx.post(
    "https://api.paygent.to/api/v1/config/plans",
    headers={"Authorization": f"Bearer {api_key}"},
    json=body,
)
r.raise_for_status()
curl -X POST https://api.paygent.to/api/v1/config/plans \
  -H "Authorization: Bearer pg_live_..." \
  -H "Content-Type: application/json" \
  -d @plans.json
// Coming soon

Request body: see Configure your first plan for the field-by-field walkthrough.

Response (201):

{
  "plans": [
    {
      "id": "abc-uuid",
      "name": "pro",
      "max_spend_per_period": 49.00,
      "max_spend_per_session": 5.00,
      "soft_gate_at": 0.80,
      "hard_gate_at": 1.00,
      "cost_rates": {...},
      "default_cost_rate": {...},
      "tool_costs": {...},
      "default_tool_cost": 0.02,
      "model_limits": {...},
      "session_timeout_minutes": 30.0,
      "pre_call_estimate": false,
      "pre_call_buffer_tokens": 4096,
      "created_at": "...",
      "updated_at": "..."
    }
  ],
  "duplicates": []
}

duplicates lists names that already exist for this product (skipped, not updated). Use PATCH to modify existing plans.

Errors: 409 Conflict if every plan in the request was a duplicate.


GET /config/plans

List all plans for this product.

r = httpx.get(
    "https://api.paygent.to/api/v1/config/plans",
    headers={"Authorization": f"Bearer {api_key}"},
)
print(r.json())
curl https://api.paygent.to/api/v1/config/plans \
  -H "Authorization: Bearer pg_live_..."
// Coming soon

Response (200):

{
  "plans": [
    { "id": "...", "name": "free", ... },
    { "id": "...", "name": "pro", ... }
  ]
}

Same PlanResponse shape as POST /config/plans.


PATCH /config/plans/{plan_id}

Partial update of a single plan. Only fields in the body are changed.

r = httpx.patch(
    f"https://api.paygent.to/api/v1/config/plans/{plan_id}",
    headers={"Authorization": f"Bearer {api_key}"},
    json={
        "max_spend_per_period": 79.00,
        "model_limits": {
            "gpt-4o": {"max_tokens_per_period": 80000},
        },
    },
)
r.raise_for_status()
curl -X PATCH https://api.paygent.to/api/v1/config/plans/$PLAN_ID \
  -H "Authorization: Bearer pg_live_..." \
  -H "Content-Type: application/json" \
  -d '{"max_spend_per_period": 79.00}'
// Coming soon

Request body: any subset of PlanSchema fields.

Response (200): the updated PlanResponse.

Errors: 404 Not Found if no plan with that id exists for this product.


Users

POST /users

Create a user.

r = httpx.post(
    "https://api.paygent.to/api/v1/users",
    headers={"Authorization": f"Bearer {api_key}"},
    json={
        "external_user_id": "user_123",
        "name": "Alice",
    },
)
r.raise_for_status()
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"}'
// Coming soon

Request body:

Field Type Required
external_user_id string (1–255) yes
name string | null no

Response (201):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "external_user_id": "user_123",
  "name": "Alice",
  "plan_name": null,
  "stripe_subscription_id": null,
  "created_at": "2026-05-06T12:00:00Z",
  "updated_at": "2026-05-06T12:00:00Z"
}

Errors: 409 Conflict if external_user_id already exists for this product.


POST /users/{user_id}/subscription

Update a user's plan. Call this after your own checkout flow succeeds. user_id here is the external_user_id.

from datetime import datetime, timedelta, timezone

r = httpx.post(
    f"https://api.paygent.to/api/v1/users/user_123/subscription",
    headers={"Authorization": f"Bearer {api_key}"},
    json={
        "plan_id": "pro-plan-uuid",
        "stripe_subscription_id": "sub_1OabCDeFghIJ",
        "period_start": datetime.now(timezone.utc).isoformat(),
        "period_end": (datetime.now(timezone.utc) + timedelta(days=30)).isoformat(),
    },
)
r.raise_for_status()
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",
    "period_start": "2026-05-01T00:00:00Z",
    "period_end": "2026-06-01T00:00:00Z"
  }'
// Coming soon

Request body:

Field Type Required
plan_id UUID yes
stripe_subscription_id string | null no
period_start ISO datetime | null conditional
period_end ISO datetime | null conditional

period_start and period_end are required when the assigned plan has a finite max_spend_per_period.

Response (200): updated UserResponse with plan_name populated.

Errors:

  • 400 Bad Request — period dates missing or invalid window
  • 404 Not Found — user or plan doesn't exist

GET /users/{user_id}/session

SDK's session-bootstrap endpoint. Returns plan config, current usage, and billing period for the user. Most apps don't call this directly — the SDK does.

r = httpx.get(
    f"https://api.paygent.to/api/v1/users/user_123/session",
    headers={"Authorization": f"Bearer {api_key}"},
)
print(r.json())
curl https://api.paygent.to/api/v1/users/user_123/session \
  -H "Authorization: Bearer pg_live_..."
// Coming soon

Response (200):

{
  "user_id": "user_123",
  "paygent_user_id": "550e8400-e29b-41d4-a716-446655440000",
  "plan": "pro",
  "sdk_enabled": true,
  "plan_config": {
    "max_spend_per_period": 49.00,
    "max_spend_per_session": 5.00,
    "soft_gate_at": 0.80,
    "hard_gate_at": 1.00,
    "model_limits": {...},
    "cost_rates": {...}
  },
  "current_usage": {
    "period_cost": 23.47,
    "period_tokens_total": 51720,
    "period_tokens_by_model": {...},
    "period_cost_by_model": {...}
  },
  "billing_period": {
    "start": "2026-05-01T00:00:00Z",
    "end": "2026-06-01T00:00:00Z"
  }
}

paygent_user_id is the backend's UUID for the user. The SDK stores it on disk and uses it to detect "user deleted and re-created" scenarios.

Errors: 404 Not Found if user doesn't exist.


GET /users/{user_id}/usage

Detailed usage data for a user.

r = httpx.get(
    f"https://api.paygent.to/api/v1/users/user_123/usage",
    headers={"Authorization": f"Bearer {api_key}"},
    params={"period": "current_period", "breakdown": "model"},
)
print(r.json())
curl "https://api.paygent.to/api/v1/users/user_123/usage?period=current_period" \
  -H "Authorization: Bearer pg_live_..."
// Coming soon

Query parameters:

Param Default Values
period current_period current_period, current_month, YYYY-MM
breakdown model model

Response (200):

{
  "user_id": "user_123",
  "period": "current_period",
  "total_cost": 23.47,
  "total_tokens": 51720,
  "tokens_by_model": {
    "gpt-4o": 31200,
    "gpt-4o-mini": 12100
  },
  "cost_by_model": {
    "gpt-4o": 18.20,
    "gpt-4o-mini": 0.95
  },
  "tool_calls_count": 14
}

Errors: 404 Not Found if user doesn't exist.


Events

POST /events/batch

Ingest a batch of usage events from the SDK. The SDK calls this in the background; you typically don't call it directly. The endpoint is idempotent — duplicate ids are silently counted as duplicates rather than rejected.

events = [
    {
        "id": "abc-1234",
        "user_id": "user_123",
        "session_id": "sess-9876",
        "timestamp": "2026-05-06T14:32:18Z",
        "model": "gpt-4o-mini",
        "input_tokens": 100,
        "output_tokens": 50,
        "total_tokens": 150,
        "cost_tokens": 0.000045,
        "cost_total": 0.000045,
    },
]
r = httpx.post(
    "https://api.paygent.to/api/v1/events/batch",
    headers={"Authorization": f"Bearer {api_key}"},
    json=events,
)
print(r.json())
curl -X POST https://api.paygent.to/api/v1/events/batch \
  -H "Authorization: Bearer pg_live_..." \
  -H "Content-Type: application/json" \
  -d '[{"id": "...", "user_id": "user_123", ...}]'
// Coming soon

Request body: JSON array (not wrapped in an object) of UsageEventSchema:

Field Type Required
id string (UUID) yes — idempotency key
user_id string yes
session_id string | null no
timestamp ISO datetime yes
model string | null no
input_tokens int default 0
output_tokens int default 0
total_tokens int default 0
tool_calls array of strings default []
cost_tokens float default 0.0
cost_tools float default 0.0
cost_total float default 0.0
metadata dict default {}

Response (200):

{
  "accepted": 8,
  "duplicates": 2
}

Gate events (audit trail)

POST /gate-events/batch

Ingest soft-/hard-gate decisions from the SDK. Used by the SDK's background sync; rarely called directly. Idempotent.

gate_events = [
    {
        "id": "abc-1234",
        "user_id": "user_123",
        "timestamp": "2026-05-06T14:32:18Z",
        "status": "hard_gate",
        "gate_reason": "total_spend",
        "usage_pct": 1.02,
        "current_value": 49.91,
        "limit_value": 49.00,
        "blocked": True,
        "message": "Spend limit reached",
    },
]
httpx.post(
    "https://api.paygent.to/api/v1/gate-events/batch",
    headers={"Authorization": f"Bearer {api_key}"},
    json=gate_events,
)
curl -X POST https://api.paygent.to/api/v1/gate-events/batch \
  -H "Authorization: Bearer pg_live_..." \
  -H "Content-Type: application/json" \
  -d '[{"id": "...", "user_id": "user_123", ...}]'
// Coming soon

Request body: array of GateEventSchema:

Field Type Required
id UUID string yes
user_id string yes
paygent_user_id string | null no
session_id string | null no
timestamp ISO datetime yes
status soft_gate | hard_gate yes
gate_reason string yes — total_spend, session_spend, or model_limit:<model>
usage_pct float yes
current_value float yes
limit_value float yes
model string | null no
blocked bool default false
message string | null no
metadata dict default {}

Response (200):

{ "accepted": 5, "duplicates": 0 }

GET /users/{user_id}/gate-events

Query gate events for a single user. Newest-first, cursor-paginated.

r = httpx.get(
    f"https://api.paygent.to/api/v1/users/user_123/gate-events",
    headers={"Authorization": f"Bearer {api_key}"},
    params={
        "status": "hard_gate",
        "blocked_only": "true",
        "since": "2026-05-01T00:00:00Z",
        "limit": 100,
    },
)
print(r.json())
curl "https://api.paygent.to/api/v1/users/user_123/gate-events?status=hard_gate&blocked_only=true&limit=100" \
  -H "Authorization: Bearer pg_live_..."
// Coming soon

Query parameters:

Param Type Default Description
status string soft_gate or hard_gate.
gate_reason string total_spend, session_spend, model_limit:<model>.
blocked_only bool false Only events that actually blocked (blocked=true).
since ISO datetime Inclusive lower bound.
until ISO datetime Exclusive upper bound.
cursor string From previous response's next_cursor.
limit int (1–500) 100 Page size.

Response (200):

{
  "user_id": "user_123",
  "events": [
    {
      "id": "abc-1234",
      "user_id": "user_123",
      "session_id": "sess-9876",
      "timestamp": "2026-05-06T14:32:18Z",
      "status": "hard_gate",
      "gate_reason": "total_spend",
      "usage_pct": 1.02,
      "current_value": 49.91,
      "limit_value": 49.00,
      "model": "gpt-4o",
      "blocked": true,
      "message": "Spend limit reached"
    }
  ],
  "next_cursor": "eyJpZCI6ImFiYy0xMjM0In0="
}

next_cursor is non-null when more pages exist. Pass it as cursor on the next call.


Health

GET /health

Liveness check. No authentication required.

r = httpx.get("https://api.paygent.to/api/v1/health")
print(r.json())
curl https://api.paygent.to/api/v1/health
// Coming soon

Response (200):

{
  "status": "ok",
  "database": "connected"
}

status is degraded if the database isn't reachable.


Error responses

All errors follow FastAPI's default shape:

{
  "detail": "User 'user_123' not found."
}

Common status codes:

Code When
400 Bad Request Invalid request body or query params
401 Unauthorized Missing or invalid Authorization header
403 Forbidden API key doesn't grant access to this resource
404 Not Found User / plan / product doesn't exist
409 Conflict Resource already exists (e.g. duplicate external_user_id)
422 Unprocessable Entity Body validation failed (Pydantic errors include a list of issues)
500 Internal Server Error Backend bug or transient failure

Next steps