Reference
API
Userland v0 control-plane endpoints and request patterns.
For agents: Use bearer auth for account-scoped reads and mutations. Send JSON. Handle structured
{ "error": { "code", "message" } }responses.
Base URL
https://api.userland.fun
The browser console is served from:
https://console.userland.fun
Auth and accounts
All account-scoped reads and mutations use bearer auth:
Authorization: Bearer ap_live_...
Content-Type: application/json
API keys authenticate the actor user, not an app user. Apps are owned by platform accounts. If no account is selected, account app lists and new app publishes use the actor’s default account. To select a team/client account for those account-scoped operations, send X-Userland-Account-Id: acct_....
X-Userland-Account-Id: acct_...
Use GET /v0/accounts to list account memberships and the default account:
curl -fsS -H "authorization: Bearer $API_KEY" \
"$USERLAND_API_BASE_URL/v0/accounts"
{
"accounts": [
{
"id": "acct_default",
"account_id": "acct_default",
"name": "alice",
"owner_user_id": "usr_alice",
"role": "owner"
},
{
"id": "acct_client",
"account_id": "acct_client",
"name": "Client Workspace",
"owner_user_id": "usr_client_owner",
"role": "admin"
}
],
"default_account_id": "acct_default"
}
Endpoints that include :app_id authorize against the app’s owning account. The selected account header cannot grant access to an app owned by another account; the actor must be a member of that account with the required role.
Browser console requests use the same canonical /v0/* business API on console.userland.fun with an HttpOnly __Host-ul_platform session cookie. Human platform auth is passwordless:
POST /v0/auth/email/requestPOST /v0/auth/email/confirmPOST /v0/auth/signup/completeGET /v0/platform/sessionDELETE /v0/platform/session
Email signin/signup sends a short-lived link to /login/confirm#token=ul_email_.... The browser page clears the fragment and consumes the token with POST /v0/auth/email/confirm; no GET request consumes the token. Existing users receive a platform session cookie. New emails receive a pending-signup cookie and complete account creation with POST /v0/auth/signup/complete.
CLI login uses browser-approved device authorization:
POST /v0/auth/device/startGET /v0/auth/device/approvalPOST /v0/auth/device/approvePOST /v0/auth/device/denyPOST /v0/auth/device/poll
The browser approves the device but never receives the raw API key. The polling CLI receives the API key exactly once after approval and stores it as the durable CLI/API credential.
API keys can also be managed by an already-authenticated API key caller or by a console session with CSRF:
GET /v0/auth/api-keysPOST /v0/auth/api-keysPATCH /v0/auth/api-keys/:api_key_idDELETE /v0/auth/api-keys/:api_key_id
Existing API keys are not revoked by signing in or out of the console. Console logout only revokes the current browser platform session; API keys continue to work until explicitly revoked through API-key management.
Listed keys and mutation responses expose only metadata: id, api_key_id, key_prefix, name, created_at, last_used_at, and revoked_at. Creation also returns the raw api_key exactly once. Rename accepts { "name": "New name" } with a 1-120 character trimmed name. Revoke is idempotent and returns revoked: false for an already-revoked owned key. List, rename, and revoke never return raw API keys or stored key hashes.
Session-authenticated mutations send the CSRF token in X-Userland-CSRF. Custom-domain add/refresh/remove and billing Checkout/Portal endpoints are available to both API-key callers and console sessions. API and agent clients should keep using api.userland.fun with bearer API keys.
Legacy password routes are intentionally disabled for launch. POST /v0/auth/token, POST /v0/platform/sessions, password reset request/confirm, and unauthenticated POST /v0/accounts return 410 with password_auth_disabled or password_signup_disabled.
Endpoints
POST /v0/accounts(disabled public password signup transition route; returns410)GET /v0/accountsGET /v0/accounts/:account_id/statusGET /v0/accounts/:account_id/limitsGET /v0/accounts/:account_id/downgrade-preview?plan=PLANPOST /v0/auth/email/requestPOST /v0/auth/email/confirmPOST /v0/auth/signup/completePOST /v0/auth/device/startGET /v0/auth/device/approvalPOST /v0/auth/device/approvePOST /v0/auth/device/denyPOST /v0/auth/device/pollGET /v0/auth/api-keysPOST /v0/auth/api-keysPATCH /v0/auth/api-keys/:api_key_idDELETE /v0/auth/api-keys/:api_key_idPOST /v0/auth/token(disabled password API-key issuance transition route; returns410)POST /v0/auth/password-reset/request(disabled password reset transition route; returns410)POST /v0/auth/password-reset/confirm(disabled password reset transition route; returns410)POST /v0/platform/sessions(disabled password session transition route; returns410on the console host)GET /v0/platform/sessionDELETE /v0/platform/sessionGET /v0/billing/planPOST /v0/billing/checkout-sessionPOST /v0/billing/portal-sessionPOST /v0/stripe/webhookGET /v0/appsPUT /v0/appsGET /v0/apps/:app_idPUT /v0/apps/:app_idDELETE /v0/apps/:app_idGET /v0/apps/:app_id/releasesPOST /v0/apps/:app_id/rollbackGET /v0/apps/:app_id/resourcesGET /v0/apps/:app_id/eventsGET /v0/apps/:app_id/analyticsGET /v0/apps/:app_id/routesPOST /v0/apps/:app_id/slugsGET /v0/apps/:app_id/slugsDELETE /v0/apps/:app_id/slugs/:slugPOST /v0/apps/:app_id/domainsGET /v0/apps/:app_id/domainsGET /v0/apps/:app_id/domains/:hostnamePOST /v0/apps/:app_id/domains/:hostname/verifyDELETE /v0/apps/:app_id/domains/:hostnamePATCH /v0/apps/:app_id/routes/:route_idGET /v0/apps/:app_id/secretsPUT /v0/apps/:app_id/secrets/:NAMEDELETE /v0/apps/:app_id/secrets/:NAMEPOST /v0/apps/:app_id/admin-invites
Use /openapi.json for the machine-readable API contract.
Legal and policy placeholders for launch review live on the product site: Terms, Privacy, and Acceptable Use. These pages are drafts pending legal review and should not be treated as final policy text.
Account status and limits
Use account endpoints to inspect plan, billing state, usage, route counts, and downgrade compatibility:
curl -fsS -H "authorization: Bearer $API_KEY" \
"$USERLAND_API_BASE_URL/v0/accounts/$ACCOUNT_ID/status"
curl -fsS -H "authorization: Bearer $API_KEY" \
"$USERLAND_API_BASE_URL/v0/accounts/$ACCOUNT_ID/limits"
curl -fsS -H "authorization: Bearer $API_KEY" \
"$USERLAND_API_BASE_URL/v0/accounts/$ACCOUNT_ID/downgrade-preview?plan=starter"
Downgrade preview returns compatible, violations, and proposed actions.
{
"account_id": "acct_...",
"plan_key": "starter",
"billing_access_state": "past_due_grace",
"grace_ends_at": "2026-05-27T00:00:00.000Z",
"active_flags": [],
"suspended": false,
"restricted": false,
"reasons": [],
"warnings": [
{
"code": "past_due_grace",
"message": "Existing apps remain online during grace, but new premium features are blocked."
}
],
"usage": {
"requests.monthly.max": 2300
},
"limits": {
"requests.monthly.max": 100000
},
"usage_period": {
"period_start": "2026-05-01T00:00:00.000Z",
"period_end": "2026-06-01T00:00:00.000Z"
},
"route_counts": {
"slugs": 1,
"custom_domains": 0
},
"compatibility_warnings": []
}
{
"account_id": "acct_...",
"plan_key": "starter",
"features": {
"app_slugs": true,
"custom_domains": true
},
"manifest_limits": {
"data.collections.max": 10
},
"deployment_limits": {
"apps.max": 10,
"app_slugs.max": 1,
"custom_domains.max": 1
},
"runtime_limits": {
"server.cpu_ms.max": 30
},
"release_limits": {
"release.file_count.max": 1000
},
"usage_limits": {
"requests.monthly.max": 100000
},
"usage": {
"requests.monthly.max": 2300
},
"usage_period": {
"period_start": "2026-05-01T00:00:00.000Z",
"period_end": "2026-06-01T00:00:00.000Z"
},
"route_counts": {
"slugs": 1,
"custom_domains": 0
},
"compatibility_warnings": []
}
{
"account_id": "acct_...",
"current_plan_key": "business",
"target_plan_key": "starter",
"compatible": false,
"violations": [
{
"type": "deployment_limit",
"key": "custom_domains.max",
"route_id": "route_...",
"current": 2,
"allowed": 1,
"message": "Starter allows 1 custom domain."
}
],
"actions": [
{
"action": "disable_route",
"route_id": "route_...",
"reason": "Custom domains over the target plan limit are disabled, not deleted."
}
]
}
Billing
Billing endpoints use the selected/default account. They work from the CLI with bearer auth and from the browser console with platform sessions. Plan reads require account.read; Checkout and Portal mutations require owner/admin billing.manage, and console mutations must include Origin and X-Userland-CSRF.
curl -fsS -H "authorization: Bearer $API_KEY" \
-H "X-Userland-Account-Id: $ACCOUNT_ID" \
"$USERLAND_API_BASE_URL/v0/billing/plan"
{
"account_id": "acct_...",
"plan_key": "free",
"entitlement_source": "default",
"billing_access_state": "active",
"grace_ends_at": null,
"billing_configured": true,
"stripe_customer": null,
"subscription": null,
"allowed_upgrade_plans": [
{
"plan_key": "starter",
"display_name": "Starter",
"price_monthly_usd": 49,
"stripe_price_id": "price_...",
"stripe_price_env": "STRIPE_PRICE_STARTER_MONTHLY",
"configured": true
}
]
}
curl -fsS -X POST \
-H "authorization: Bearer $API_KEY" \
-H "X-Userland-Account-Id: $ACCOUNT_ID" \
-H 'content-type: application/json' \
-d '{"plan_key":"starter"}' \
"$USERLAND_API_BASE_URL/v0/billing/checkout-session"
curl -fsS -X POST \
-H "authorization: Bearer $API_KEY" \
-H "X-Userland-Account-Id: $ACCOUNT_ID" \
"$USERLAND_API_BASE_URL/v0/billing/portal-session"
Checkout accepts only local plan keys: starter, business, business_plus, or agency. The server chooses Stripe price IDs from environment configuration and rejects raw price_... IDs. Portal sessions require an existing Stripe customer link.
POST /v0/stripe/webhook verifies Stripe’s raw signed payload and records event IDs idempotently. It processes checkout.session.completed, checkout.session.expired, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, and invoice.payment_failed; unsupported event types are acknowledged as ignored. Active and trialing subscriptions grant the mapped local plan with source stripe, payment failures start past_due_grace, recovery re-enables eligible billing-disabled routes, and canceled or terminal subscriptions move local entitlements back to free without deleting account data. Browser redirects never grant local entitlements by themselves.
Publish request
PUT /v0/apps creates an app and first release. PUT /v0/apps/:app_id publishes another release for an existing app.
For a non-default account, include X-Userland-Account-Id when creating the app:
curl -fsS -X PUT \
-H "authorization: Bearer $API_KEY" \
-H "X-Userland-Account-Id: $ACCOUNT_ID" \
-H 'content-type: application/json' \
-d @publish.json \
"$USERLAND_API_BASE_URL/v0/apps"
{
"app": { "name": "Hello App", "visibility": "public" },
"runtime": { "static_root": "public", "fallback": "index.html" },
"resources": {},
"files": [
{
"path": "public/index.html",
"content_type": "text/html; charset=utf-8",
"content_base64": "PCFkb2N0eXBlIGh0bWw+PGgxPkhlbGxvPC9oMT4="
}
],
"message": "Initial release",
"provenance": { "source": "agent" }
}
The response includes status, app_id, account_id, release_id, origin, previous_release_id, and activation.
Activation
livemeans the release is serving.pending_secretsmeans required secrets are missing.requires_migrationmeans the release was stored but did not become live because a resource change needs an explicit migration plan.
Events and rollback
Use GET /v0/apps/:app_id/events?severity=error&limit=25 to inspect account-visible failures. Use rollback only with a compatible retained release.
App analytics
Use GET /v0/apps/:app_id/analytics?range=7d|30d|90d to read paid, owner-visible aggregate analytics for an app.
curl -fsS -H "authorization: Bearer $API_KEY" \
"$USERLAND_API_BASE_URL/v0/apps/$APP_ID/analytics?range=30d"
The response includes traffic totals, UTC daily request series, status buckets, top normalized paths, referrer domains, route and runtime target breakdowns, app-user signup and session counts, job and webhook status counts, and recent error-level app events.
The endpoint requires app read access and the account’s app_analytics entitlement. Non-entitled accounts receive 402 entitlement_required. Requested ranges are clamped to the plan retention window: Starter 7 days, Business 30 days, Business Plus and Agency 90 days.
App Analytics stores aggregate rollups only. It strips query strings, stores referrer domains instead of full referrer URLs, and does not return request bodies, cookies, authorization headers, app-user emails, session hashes, raw app-user IDs, or per-user timelines.
Routes
Slug routes and custom domains are deployment aliases. The canonical app-id origin remains available when an alias is disabled or deleted, unless the app or account itself is suspended or taken down.
Create a slug:
curl -fsS -X POST \
-H "authorization: Bearer $API_KEY" \
-H 'content-type: application/json' \
-d '{"slug":"demo"}' \
"$USERLAND_API_BASE_URL/v0/apps/$APP_ID/slugs"
List and remove aliases:
curl -fsS -H "authorization: Bearer $API_KEY" \
"$USERLAND_API_BASE_URL/v0/apps/$APP_ID/routes"
curl -fsS -X DELETE \
-H "authorization: Bearer $API_KEY" \
"$USERLAND_API_BASE_URL/v0/apps/$APP_ID/slugs/demo"
Create a custom domain:
curl -fsS -X POST \
-H "authorization: Bearer $API_KEY" \
-H 'content-type: application/json' \
-d '{"hostname":"portal.example.com"}' \
"$USERLAND_API_BASE_URL/v0/apps/$APP_ID/domains"
Verify and remove a custom domain:
curl -fsS -X POST \
-H "authorization: Bearer $API_KEY" \
-H 'content-type: application/json' \
-d '{}' \
"$USERLAND_API_BASE_URL/v0/apps/$APP_ID/domains/portal.example.com/verify"
curl -fsS -X DELETE \
-H "authorization: Bearer $API_KEY" \
"$USERLAND_API_BASE_URL/v0/apps/$APP_ID/domains/portal.example.com"
Slug creation requires app_slugs and app_slugs.max. Custom-domain creation requires custom_domains and custom_domains.max. Custom domains start as pending_dns and become active after DNS ownership and certificate verification.
The route response includes raw provider/support metadata in verification and stable customer setup records in dns_instructions. In local or manually operated environments, verification contains a Userland DNS TXT challenge:
{
"method": "dns_txt",
"name": "_userland.portal.example.com",
"value": "userland-route=route_..."
}
Production setup should show subdomain customers CNAME <hostname> customers.userland.fun; proxy-fallback.userland.fun is the internal Cloudflare for SaaS fallback origin, not the customer-facing CNAME target. For likely apex hostnames such as example.com, use the dns_instructions.traffic note and provider-specific apex CNAME or CNAME flattening when the customer’s DNS provider supports it; otherwise use ALIAS, ANAME, or equivalent apex aliasing. Do not show a normal apex CNAME as the only instruction.
When the API Worker has CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID and CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN, custom-domain creation provisions a Cloudflare for SaaS Custom Hostname. The API can fall back to CLOUDFLARE_API_TOKEN, but production should use the dedicated custom-hostnames token. The route response then includes the provider ID, provider status, SSL status, and Cloudflare validation records when available. Manual {"dns_verified":true} activation is reserved for platform-admin local/test operation.
App-user invites
POST /v0/apps/:app_id/admin-invites creates an invite for an app user on a published app that declares auth.mode: "app_users" and the requested roles.
curl -fsS -X POST \
-H "authorization: Bearer $API_KEY" \
-H 'content-type: application/json' \
-d '{"email":"[email protected]","roles":["admin"]}' \
"$USERLAND_API_BASE_URL/v0/apps/$APP_ID/admin-invites"
This creates an app-user invite only. It does not add a platform account member or grant CLI/API access.
Operational errors
API errors use this shape:
{
"error": {
"code": "quota_exceeded",
"message": "Monthly request quota exceeded for the current plan.",
"details": {
"metric": "requests.monthly.max",
"plan_key": "free",
"limit": 10000,
"current": 10000,
"increment": 1,
"upgrade_required": true
}
}
}
Operational controls use stable error.code values:
| Code | Status | Meaning |
|---|---|---|
account_suspended |
403 |
Account-level abuse, security, or legal flag blocks mutation or serving. |
app_suspended |
403 |
App-level abuse or security flag blocks mutation or serving. |
app_takedown |
451 |
App is unavailable due to legal takedown. |
route_disabled |
404 or 410 |
Slug or custom-domain route is pending, disabled, or deleted. Canonical app-id origin may still serve. |
billing_restricted |
402 |
Account billing state blocks the requested premium operation. |
quota_exceeded |
402 |
Usage quota blocks the narrow operation causing the overage. |
plan_limit_exceeded |
402 |
Requested operation would exceed the current plan limit. |
billing_not_configured |
503 |
Stripe secret or price configuration is missing for the requested billing operation. |
invalid_billing_plan |
400 |
Checkout received a free/internal/unknown plan key or a raw Stripe price ID. |
billing_customer_missing |
409 |
Customer Portal was requested before the account had a Stripe customer link. |
invalid_stripe_signature |
400 |
Stripe webhook verification failed or the signature header is missing. |
abuse_throttled |
429 |
Rate limiter blocked the request due to abuse or high-risk free-plan activity. |
downgrade_incompatible |
409 |
Requested downgrade cannot proceed until incompatible apps/routes/features are remediated. |
domain_pending_verification |
409 |
Custom domain cannot become active until DNS ownership/certificate verification completes. |
custom_hostname_provider_conflict |
409 |
Custom hostname already exists at the provider and is not owned by the requested route. |
custom_hostname_provider_failed |
502 |
Custom hostname provider request failed. |
Disabled route responses are platform-owned and do not dispatch user runtime:
{
"error": {
"code": "route_disabled",
"message": "This route is not available.",
"details": {
"route_id": "route_...",
"route_type": "slug",
"hostname": "demo.apps.userland.fun",
"status": "disabled_downgrade"
}
}
}