# Step 2: Provision End Users When a user signs up on the platform, provision them in Assistiv so they get an API key for inference. This step walks you through finding the right hook point, mapping the platform's plans to Assistiv budgets, and implementing it. --- ## Before you implement — research the codebase Do NOT start writing code yet. First, figure out these things by reading the codebase. Only ask the developer about **business decisions** (pricing, display units). Everything else you should determine from the code. ### 1. Find where users are created (figure this out yourself) Search the codebase for the signup / user-creation flow. Look for: - Auth callback handlers (e.g. Supabase `onAuthStateChange`, NextAuth `signIn` callback, Clerk webhook, Firebase `onCreate` trigger) - `POST /signup` or `POST /register` API routes - User model creation (`createUser`, `insertUser`, `User.create`, etc.) - Webhook handlers for auth providers (Stripe `customer.created`, Auth0 post-login action, etc.) The goal is to provision **once** during signup, store the result in the DB, and read from the DB on every subsequent request. Do NOT call `POST /end-users` on every login or session start. You need to handle two cases: **New users (signup flow):** Hook into the signup flow so every new user gets provisioned automatically. If there is no server-side signup hook (e.g. auth is entirely client-side), add a provisioning endpoint to the server-side component you set up in Step 1. The frontend calls it once after detecting a **first-time** user (check your DB — if the user already has an `assistiv_api_key` stored, skip provisioning entirely). **Existing users (backfill):** The platform likely already has users who signed up before Assistiv was integrated. These users need to be provisioned too. Build a check into the login/session flow: if the user exists in your DB but has no `assistiv_api_key`, provision them on their next login. This is a one-time migration that happens lazily — existing users get provisioned the first time they hit a feature that needs Assistiv. Alternatively, write a one-time migration script that provisions all existing users in bulk. ### 2. Identify the stable user ID (figure this out yourself) Assistiv maps your users via `external_id` — a stable, unique identifier from your system. Use whatever the app already uses as its primary user identifier: - Database primary key (`user.id`) - Auth provider ID (`auth.uid`, `session.user.id`, `sub` claim) Pick the one that's already threaded through the app — whatever gets passed around as "the user ID" in components, API calls, and database queries. ### 3. Find where to store the API key (figure this out yourself) `POST /end-users` returns a `raw_key` (`sk-eu_*`). You **must persist it in the database** and reuse it on every subsequent request for that user. Do NOT use in-memory caches, do NOT re-provision to get a fresh key on every request — that creates unnecessary load on both your server and Assistiv's. The key is created once and reused forever (until explicitly revoked). Store it permanently. Look for: - An existing user profile/settings table → add an `assistiv_api_key` column - An existing credentials/api-keys table → add a row - If neither exists, create a `user_settings` or `user_credentials` table The `sk-eu_*` key is used in two places: - **Server-side** — for proxied inference calls (Step 4) - **Client-side** — for end-user self-service like budget checks (`GET /v1/me/budget`) where the frontend calls Assistiv directly The key is safe to send to the client — it's scoped to one user and can only access their own data. Store it in the DB; the server reads it for inference proxying, and can also return it to the authenticated frontend when needed. **Scope enforcement (Epic 2).** End-user keys (`sk-eu_*`) are hard-blocked from mutating ANY other user's data, even on the same platform — the server returns `403 Access denied` on `POST /budget`, `POST /budget/topup`, `POST /budget/debit`, `PATCH /budget`, and `DELETE /budget` if the URL's end-user-id doesn't match the key's bound end-user. This is enforced at the route layer, not just the data layer. So: do not try to use one user's key to poke at another user's state; the only legitimate use of a `sk-eu_*` key from a browser is against `/v1/me/*` and inference. Do NOT ask the developer where to store it. Find the most natural place in the existing schema. If the DB has no user-related tables, create one. ### 4. Map the platform's plans to Assistiv budgets (ask the developer) This is the one thing you need the developer's input on — it's a business decision, not a code question. Search the codebase for plan/tier definitions. Look for: - Pricing constants (`PLANS`, `TIERS`, `PRICING`) - Stripe price IDs or product definitions - Feature flags or plan checks (`user.plan === "pro"`) - Billing/subscription logic Once you find the plans, present the developer with a mapping table and ask them to confirm or adjust: **Ask the developer:** "I found your plans in `[file path]`. Here's how I'd map them to Assistiv budgets. Does this look right? | Plan | Your price | Assistiv budget (real AI spend) | Period | |------|-----------|-------------------------------|--------| | free | $0 | $X.XX/month | monthly | | pro | $Y/mo | $Z.ZZ/month | monthly | Also: - Have you configured the display unit and credits-per-USD on the Assistiv dashboard? (If not, what unit do you want users to see — credits, tokens, messages — and how many per dollar of real AI spend?) - Do these budget amounts look right, or do you want different caps?" If there are no plans in the codebase, ask the developer what the tiers should be. --- ## Implementation Once you have the plan mapping confirmed, build a provisioning function in the platform's language and framework. Fit it into the existing codebase style — do not copy-paste generic examples. ### What the function must do Build a single server-side function that runs during user signup. It makes three sequential API calls to Assistiv, all authenticated with the platform key (`sk-plat_*`) from the environment: **Step A — Create the end user:** ``` POST https://api.assistiv.ai/v1/platforms/{platformId}/end-users Authorization: Bearer {ASSISTIV_PLATFORM_KEY} Body: external_id: the user's stable ID from your system display_name: the user's name (optional) metadata: { "plan": "free" } (or whatever plan they signed up for) Response (201): id: Assistiv's internal user ID (UUID) — use this for budget/rate-limit calls api_key.raw_key: the sk-eu_* key — store this in your DB immediately ``` **Step B — Create a budget for their plan:** ``` POST https://api.assistiv.ai/v1/platforms/{platformId}/end-users/{assistivUserId}/budget Authorization: Bearer {ASSISTIV_PLATFORM_KEY} Body: max_usd: real AI spend cap for this plan (e.g. 1.00 for free, 5.00 for pro) period: "monthly", "daily", or "one_time" auto_replenish: true if periodic (resets each period) replenish_amount: same as max_usd for periodic plans ``` Display values (credits, tokens, etc.) are computed by Assistiv from the dashboard config — your code only sets `max_usd`. **Step C — (Optional) Set rate limits for the plan:** ``` POST https://api.assistiv.ai/v1/platforms/{platformId}/end-users/{assistivUserId}/rate-limits Authorization: Bearer {ASSISTIV_PLATFORM_KEY} Body: rpm_limit: requests per minute (null = unlimited) tpm_limit: tokens per minute (null = unlimited) rpd_limit: requests per day (null = unlimited) ``` Skip this call if the plan uses platform defaults (set on the dashboard). ### Where to hook it in Call this function from the signup flow you identified in step 1 above. If auth is client-side only (no server-side signup hook), create a provisioning endpoint on the server-side component from Step 1 and have the frontend call it after detecting a first-time session. **Important:** Steps B and C are only needed when the user is **newly created** (`201`). If `POST /end-users` returns `200` (user already exists), the budget and rate limits already exist — do NOT re-create them or you'll get a `409` duplicate error. Check the status code: - `201` → new user, proceed with Steps B and C - `200` → existing user, skip Steps B and C **Step D — Wire up the budget check on the frontend:** After provisioning, the frontend needs to fetch and display the user's balance. This is a direct client-side call using the end-user key: ``` GET https://api.assistiv.ai/v1/me/budget Authorization: Bearer {sk-eu_* key} Response: unit: "credits", "messages", "tokens", or whatever the platform configured max: total for the period (e.g. 100) used: consumed so far (e.g. 20) remaining: what's left (e.g. 80) period: "monthly", "daily", "one_time" period_start: ISO 8601 timestamp ``` No raw USD amounts are exposed to the end user. The unit and the rules that drive deductions are configured server-side on `platforms.settings.end_user_wallet` — see Step 3 for the dual-ledger + rules engine model. Wire this into the frontend wherever usage/balance is shown — settings page, usage bar, etc. The frontend calls Assistiv directly with the `sk-eu_*` key (it's safe for client-side use). **CORS note:** The Assistiv API (`api.assistiv.ai`) returns CORS headers for browser requests. If you're developing locally against the production API, make sure the frontend's `ASSISTIV_API_BASE` env var points to `https://api.assistiv.ai/v1` (not `localhost`) unless you're running the Assistiv backend locally. ### Idempotency (retry safety, not a key-retrieval strategy) `POST /end-users` is **idempotent on `external_id`**: - First call → `201 Created` with a new user + new `sk-eu_*` key - Repeat calls → `200 OK` with the existing user + a new `sk-eu_*` key This exists for **retry safety** — if the first call succeeded but you didn't store the key (network error, crash), you can call again to get a new one. It is NOT a substitute for storing the key. The correct flow is: 1. Call `POST /end-users` once during signup 2. Store the `raw_key` in your database 3. Read it from your database on every subsequent request for that user Do NOT call `POST /end-users` on every login or session start to "get" the key. That creates unnecessary API keys and wastes resources. Store it once, reuse forever. Previous keys remain valid until explicitly revoked via `PATCH /api-keys/{id}` with `is_active: false`. --- ## API Reference ### Server-side endpoints (platform key `sk-plat_*`) These are called from your backend. Never call these from the browser. #### POST /v1/platforms/{platformId}/end-users — create end user Request body: ```json { "external_id": "user-123", "display_name": "Jane Smith", "metadata": { "plan": "pro" } } ``` Fields: - `external_id` (string, required) — Your stable user identifier. 1-255 chars. Unique per platform. - `display_name` (string) — Human-readable name, 1-100 chars. - `metadata` (object) — Arbitrary JSON, stored as-is. Response (201): ```json { "id": "uuid", "platform_id": "uuid", "external_id": "user-123", "display_name": "Jane Smith", "metadata": { "plan": "pro" }, "is_active": true, "created_at": "2026-04-08T10:30:00Z", "updated_at": "2026-04-08T10:30:00Z", "api_key": { "id": "uuid", "platform_id": "uuid", "end_user_id": "uuid", "key_prefix": "sk-eu_10", "name": "Default key", "scopes": ["inference"], "is_active": true, "created_at": "2026-04-08T10:30:00Z", "raw_key": "sk-eu_103c5bb1fd2bd35d..." }, "budget": null } ``` IMPORTANT: `api_key.raw_key` is only returned here. Save it immediately. If your platform has `settings.default_budget_usd` configured on the website, a monthly budget is auto-created for the new user and returned as `budget`. Auto-create writes an `opening` row to the budget ledger, so `GET /budget/transactions` shows the budget's full history from minute zero (see Step 3). #### GET /v1/platforms/{platformId}/end-users — list end users Query params: - `page` (default 1) - `limit` (default 20, max 100) - `external_id` (string, optional) — Filter to a single user by your stable ID. Returns the standard list shape (or empty list if not found). Use for cheap lookup without scanning pages. #### GET /v1/platforms/{platformId}/end-users/{endUserId} — get one Returns the end user resource, unwrapped. #### PATCH /v1/platforms/{platformId}/end-users/{endUserId} — update All fields optional: ```json { "display_name": "Jane D. Smith", "metadata": { "plan": "enterprise" }, "is_active": true } ``` `metadata` replaces the stored object entirely (no deep merge). #### DELETE /v1/platforms/{platformId}/end-users/{endUserId} — delete Cascades to their API keys, budgets, rate-limit overrides, and MCP connections. Returns 204 No Content. #### GET /v1/platforms/{platformId}/api-keys?type=end_user — list keys Query params: - `type` — `"platform"` or `"end_user"` (default `"platform"`) - `page` (default 1) - `limit` (default 20, max 100) Full key hashes are never exposed. Only `key_prefix`. #### POST /v1/platforms/{platformId}/api-keys — create additional key Include `end_user_id` in the body to make it an end-user key. Request body: ```json { "end_user_id": "end-user-uuid", "name": "Mobile app key", "expires_at": "2027-01-01T00:00:00Z" } ``` Fields: - `end_user_id` (string) — Creates an end-user key scoped to that user. - `name` (string) — Display name, 1-100 chars. - `scopes` (string[]) — Defaults to `["inference"]` if omitted. - `expires_at` (string) — ISO 8601 expiration, or omit for no expiry. Response (201) includes `raw_key` **once only**. #### PATCH /v1/platforms/{platformId}/api-keys/{keyId}?type=end_user — revoke/rename ```json { "name": "Renamed", "is_active": false } ``` Setting `is_active: false` revokes — Redis cache invalidated immediately. #### DELETE /v1/platforms/{platformId}/api-keys/{keyId}?type=end_user — delete Soft-delete (revoke) a key. Returns 204. --- ### Client-side endpoints (end-user key `sk-eu_*`) These can be called directly from the browser using the end-user's key. Safe for frontend use — the key is scoped to one user's data. #### GET /v1/me/budget — get the user's balance Returns the display ledger only — no raw USD exposed. The response unit is whatever the platform admin configured on `settings.end_user_wallet.unit` (default "credits"). ```json { "unit": "credits", "max": 100, "used": 20, "remaining": 80, "period": "monthly", "period_start": "2026-04-01T00:00:00Z" } ``` **Dual-gated**: returns 404 `not_found` when EITHER the platform has not opted in to the wallet display OR the per-user display ledger has not been initialized. End users cannot distinguish the two reasons by timing or response shape. See Step 3 for how to configure rules and initialize. --- Next: [Step 3 — Budgets & Rate Limits](https://www.assistiv.ai/docs/integration/step-3-budgets-and-rate-limits.txt)