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:

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:

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:

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

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

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"
    }
  }
}