CloudArch HTTP API

Overview

The CloudArch REST API exposes the same surface used by the web app: scripts, comments, collections, profiles, quotas, billing, and account self-service. All requests are made over HTTPS to https://api.cloud-arch.ru and accept and return application/json. All resource identifiers are UUIDs unless noted.

This is a stable /v1 surface. Breaking changes ship under a new prefix.

Authentication

Generate an API key from /workspace/api and pass it as a Bearer token on every request:

Authorization: Bearer ca_<your-key>

Each key is rate limited to 1000 requests per hour. Keys are user-scoped: a request authenticated with your key acts as you and is bound by your tier limits, quotas, and ownership rules.

When calling from the web origin you can also rely on the session cookie (credentials: 'include'). The Bearer token takes precedence when both are present.

Quickstart

List public scripts in the gallery:

curl -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/scripts/gallery

Successful responses look like { "items": [...], "totalCount": <int> }. Most endpoints return a single resource as { "item": {...} } and a write-only acknowledgement as { "ok": true }.


Scripts

GET /v1/scripts/gallery

Search and paginate the public + template gallery. Optional query: query, category, tag, page (default 1), pageSize.

curl "https://api.cloud-arch.ru/v1/scripts/gallery?query=kafka&page=1&pageSize=20"
{
  "items": [
    {
      "id": "8c7e...",
      "name": "Kafka cluster",
      "slug": "kafka-cluster",
      "description": "Three-broker Kafka with ZooKeeper",
      "type": "topology",
      "category": "Infrastructure",
      "isPublic": true,
      "viewCount": 421,
      "starCount": 7,
      "tags": ["kafka", "messaging"]
    }
  ],
  "totalCount": 1
}

GET /v1/scripts/:id

Fetch one script by UUID or by slug. Returns 404 if the script is private and the requester is not the owner.

curl -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/scripts/kafka-cluster
{
  "item": {
    "id": "8c7e...",
    "name": "Kafka cluster",
    "code": "const topology = new TopologyBuilder(true);\n...",
    "type": "topology",
    "isPublic": true
  }
}

POST /v1/scripts

Create a new script. Subject to draftsPerDay quota; publishPerDay is checked separately if isPublic is true. Topology scripts are validated server-side; validation failures return 422.

curl -X POST -H "Authorization: Bearer ca_..." \
     -H "Content-Type: application/json" \
     -d '{"name":"My diagram","type":"topology","code":"const t = new TopologyBuilder(true);","isPublic":false}' \
     https://api.cloud-arch.ru/v1/scripts
{
  "item": {
    "id": "f4d2...",
    "name": "My diagram",
    "type": "topology",
    "isPublic": false
  }
}

PATCH /v1/scripts/:id

Update fields on a script you own. Body keys are optional; flipping isPublic: true re-checks the publish quota and stamps publishedAt.

curl -X PATCH -H "Authorization: Bearer ca_..." \
     -H "Content-Type: application/json" \
     -d '{"description":"Updated copy","isPublic":true}' \
     https://api.cloud-arch.ru/v1/scripts/f4d2...
{ "item": { "id": "f4d2...", "isPublic": true, "publishedAt": "2026-04-30T10:00:00.000Z" } }

DELETE /v1/scripts/:id

Permanently delete a script you own.

curl -X DELETE -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/scripts/f4d2...
{ "ok": true }

POST /v1/scripts/:id/star

Toggle a star on a script. Returns the new state.

curl -X POST -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/scripts/8c7e.../star
{ "starred": true }

POST /v1/scripts/:id/view

Increment the view counter. No auth required. Use sparingly: this is intended for embed/viewer pages, not crawlers.

curl -X POST https://api.cloud-arch.ru/v1/scripts/8c7e.../view
{ "ok": true, "viewCount": 422 }

Comments

GET /v1/scripts/:id/comments

List comments on a script. Optional query: targetType (script | node | edge) and targetId to filter to a specific node/edge anchor.

curl "https://api.cloud-arch.ru/v1/scripts/8c7e.../comments?targetType=node&targetId=broker-1"
{
  "items": [
    {
      "id": "a91b...",
      "scriptId": "8c7e...",
      "userId": "u-123",
      "content": "Why three brokers and not five?",
      "targetType": "node",
      "targetId": "broker-1",
      "createdAt": "2026-04-29T08:21:00.000Z",
      "author": { "username": "ada", "fullName": "Ada Lovelace", "avatarUrl": null }
    }
  ]
}

POST /v1/scripts/:id/comments

Add a comment. Subject to commentsPerHour quota. targetType is required; targetId is required for node and edge targets and ignored for script.

curl -X POST -H "Authorization: Bearer ca_..." \
     -H "Content-Type: application/json" \
     -d '{"content":"Nice diagram!","targetType":"script"}' \
     https://api.cloud-arch.ru/v1/scripts/8c7e.../comments
{ "item": { "id": "a91c...", "content": "Nice diagram!", "targetType": "script" } }

DELETE /v1/comments/:commentId

Delete one of your own comments. The user-scoped variant is preferred over the legacy DELETE /v1/scripts/:id/comments/:commentId — both work.

curl -X DELETE -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/comments/a91c...
{ "ok": true }

Collections

Collections are private folders that group scripts owned or starred by the user.

GET /v1/collections

List your collections, including the script count for each.

curl -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/collections
{
  "items": [
    { "id": "c1...", "name": "Messaging", "color": "#7c3aed", "scriptCount": 3, "createdAt": "2026-04-01T00:00:00.000Z" }
  ]
}

POST /v1/collections

Create a new collection. Body: { name, description?, color? }.

curl -X POST -H "Authorization: Bearer ca_..." \
     -H "Content-Type: application/json" \
     -d '{"name":"Messaging","color":"#7c3aed"}' \
     https://api.cloud-arch.ru/v1/collections
{ "item": { "id": "c1...", "name": "Messaging", "scriptCount": 0 } }

POST /v1/collections/:id/scripts

Attach a script to a collection. Body: { scriptId }. The script must be owned by you or public.

curl -X POST -H "Authorization: Bearer ca_..." \
     -H "Content-Type: application/json" \
     -d '{"scriptId":"8c7e..."}' \
     https://api.cloud-arch.ru/v1/collections/c1.../scripts
{ "ok": true }

DELETE /v1/collections/:id/scripts/:scriptId

Remove a script from a collection. The script itself is not deleted.

curl -X DELETE -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/collections/c1.../scripts/8c7e...
{ "ok": true }

Profiles

GET /v1/profiles/by-username/:username

Public profile lookup. Returns 404 if no profile with that username exists.

curl https://api.cloud-arch.ru/v1/profiles/by-username/ada
{
  "item": {
    "id": "u-123",
    "username": "ada",
    "fullName": "Ada Lovelace",
    "avatarUrl": null,
    "createdAt": "2026-01-04T11:30:00.000Z"
  }
}

Quota

GET /v1/quota/me

Current usage and effective limits for the authenticated user. Used by the dashboard widget. Returns tierId, isAdmin, and an array of { key, used, limit, windowSeconds } entries covering publishPerDay, draftsPerDay, commentsPerHour, and aiChatPerDay.

curl -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/quota/me
{
  "tierId": "free",
  "isAdmin": false,
  "quotas": [
    { "key": "publishPerDay", "used": 1, "limit": 5, "windowSeconds": 86400 },
    { "key": "draftsPerDay", "used": 4, "limit": 20, "windowSeconds": 86400 },
    { "key": "commentsPerHour", "used": 0, "limit": 30, "windowSeconds": 3600 },
    { "key": "aiChatPerDay", "used": 0, "limit": 10, "windowSeconds": 86400 }
  ]
}

Billing

Billing endpoints require authentication, except the two read-only ones below (/v1/billing/status and /v1/billing/bundles) — a paywall has to name its price before the visitor has an account. The payment provider is deployment-configured (PAYMENT_PROVIDER): T-Bank acquiring or CloudPayments. A 503 response means billing is not configured for the current deployment.

The provider decides the shape of a checkout response, so read it by which field is present — paymentUrl (T-Bank: redirect the browser there) or widget (CloudPayments: parameters for the in-page widget). Never assume one of them.

GET /v1/billing/status

Whether this deployment accepts payments at all. No authentication. Clients use it to render an “unavailable” state instead of a checkout CTA that would 503 after the click.

curl https://api.cloud-arch.ru/v1/billing/status
{ "configured": true }

GET /v1/billing/bundles

The sellable one-off bundles and their prices. No authentication required.

With a session, prices are quoted for that user: a checkout created before a price change is reused as-is by POST /v1/billing/purchase-bundle (re-minting an invoice is how a buyer ends up charged twice), so this endpoint returns the pending amount rather than the live price. Without a session, the live price list.

curl https://api.cloud-arch.ru/v1/billing/bundles
{
  "items": [
    {
      "id": "system-design-cases",
      "name": "System Design Cases",
      "description": "System Design Cases — все кейсы интервью по системному дизайну",
      "priceKopeks": 99000,
      "currency": "RUB"
    }
  ]
}

GET /v1/billing/me

Return the user’s active subscription (if any), the matching plan, and the 50 most recent payments.

curl -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/billing/me
{
  "subscription": { "id": "s1...", "planId": "premium-monthly", "status": "active", "cancelAtPeriodEnd": false },
  "plan": { "id": "premium-monthly", "priceKopeks": 49900, "intervalDays": 30 },
  "payments": [
    { "id": "p1...", "amountKopeks": 49900, "status": "succeeded", "createdAt": "2026-04-01T00:00:00.000Z" }
  ]
}

POST /v1/billing/subscribe

Start a subscription. Body: { planId, consent }. Returns the new subscription id, the payment row id and the provider-specific checkout (see above). Returns 409 already_subscribed when an active subscription already exists. Re-clicking the same plan while a checkout is still pending reuses that pending payment instead of creating a second one.

consent is required and records agreement to recurring charges: { acceptedRecurring: true, recurringPolicyVersion, locale, path? }. acceptedRecurring must be literally true — a missing or false value is a 400 and no subscription or payment row is created. The consent is stored with the user id, the timestamp and the policy version before any payment is created.

curl -X POST -H "Authorization: Bearer ca_..." \
     -H "Content-Type: application/json" \
     -d '{"planId":"premium-monthly"}' \
     https://api.cloud-arch.ru/v1/billing/subscribe
{
  "subscriptionId": "s1...",
  "paymentId": "p1...",
  "paymentUrl": "https://securepay.tinkoff.ru/new/...",
  "orderId": "11111111-1111-4111-8111-111111111111"
}

POST /v1/billing/promo/validate

Preview a promo-code discount before paying. Body: { code, target, planId? , bundleId? } where target is subscription or bundle. Answers 200 in both cases — { valid: true, ... } with the computed amounts, or { valid: false, reason } where reason is one of not_found, inactive, not_started, expired, exhausted, wrong_target, already_used.

This is a preview, not a reservation: a code can run out between the preview and the payment, so the authoritative check happens during checkout.

{
  "valid": true,
  "code": "LAUNCH50",
  "discountType": "percent",
  "discountValue": 50,
  "originalAmountKopeks": 29900,
  "discountKopeks": 14950,
  "finalAmountKopeks": 14950
}

Both POST /v1/billing/subscribe and POST /v1/billing/purchase-bundle accept an optional promoCode. The discount applies to that payment only — a subscription keeps charging the full plan price on renewal. A rejected code answers 400 { error, reason } and creates no payment. Re-clicking checkout while a pending payment exists reuses that payment (discount included) and does not spend the code twice.

POST /v1/billing/consent/revoke

Withdraw consent for automatic payments. Separate from cancelling the subscription: besides turning auto-renewal off it deletes the stored card token, so nothing can be charged even if a flag is later lost. Access stays until the end of the paid period. Returns 404 when the user has no active subscription.

curl -X POST -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/billing/consent/revoke
{
  "subscription": { "id": "s1...", "cancelAtPeriodEnd": true, "status": "active" },
  "recurringConsent": null
}

Under CloudPayments the same endpoint answers with a widget object instead of paymentUrl/orderId.

PATCH /v1/billing/subscription

Toggle cancelAtPeriodEnd on the active subscription. The subscription stays active until the period ends; set the flag back to false to resume.

curl -X PATCH -H "Authorization: Bearer ca_..." \
     -H "Content-Type: application/json" \
     -d '{"cancelAtPeriodEnd":true}' \
     https://api.cloud-arch.ru/v1/billing/subscription
{ "subscription": { "id": "s1...", "cancelAtPeriodEnd": true, "status": "active" } }

Account

DELETE /v1/account

Permanently delete the authenticated user. Cascades remove sessions, profiles, scripts, stars, comments, collections, AI usage, quota overrides, subscriptions, and payments. Audit-event rows survive with the actor email snapshot. This action cannot be undone.

curl -X DELETE -H "Authorization: Bearer ca_..." \
     https://api.cloud-arch.ru/v1/account
{ "ok": true }

Reports

POST /v1/reports

File a content report against a public script. Body: { targetType: "script", targetId, reason }. The reason must be 10-2000 characters.

curl -X POST -H "Authorization: Bearer ca_..." \
     -H "Content-Type: application/json" \
     -d '{"targetType":"script","targetId":"8c7e...","reason":"This diagram contains spam links in the description."}' \
     https://api.cloud-arch.ru/v1/reports
{ "ok": true, "id": "r1..." }

Access tiers and content gating

Scripts carry a section (explore | cases | concepts) and an accessTier (free | premium). Every read path that would return code or readme runs the access gate first, so a premium case is visible to everyone but readable only to callers who are entitled to it.

A caller is entitled when any of these hold:

  • the script is not in the cases section (explore and concepts are always free), or
  • accessTier is free, or
  • the caller is the script’s creator, or
  • the caller is a member of the workspace that owns the script, or
  • the caller owns the bundle derived from the script’s category:<slug> tag (category:system-design → bundle system-design-cases).

A premium case whose category tag is missing or unrecognised fails closed.

When the gate denies access the response is not a 403 — it is a normal 200 with the paid fields emptied:

{
  "item": {
    "id": "8c7e...",
    "name": "Design Payment System",
    "accessTier": "premium",
    "code": "",
    "readme": null,
    "readmePreview": "## Requirements\n\n...",
    "shareToken": null,
    "userCanAccess": false
  }
}

Always branch on userCanAccess rather than on the presence of code. shareToken is withheld too, so an ungated caller cannot resolve the script through GET /v1/share/:token.

readmePreview is the mirror image of readme: it carries a bounded teaser only when access is denied, and is null for callers who can read readme itself. The teaser stops at the third ## heading (so the first two sections stay whole) and is capped at 2200 characters and 45% of the readme, whichever cuts first. If the cut leaves nothing but a heading, the field is null rather than an empty-looking card.


Endpoints not covered on this page

This page documents the core content surface. The /v1 API is larger; the rest is stable but undocumented here, and the shapes are best read off the route files in apps/api/src/routes/.

AreaEndpoints
Script discoveryGET /v1/scripts/search, /public, /featured, /templates, /categories, /tags, /starred, /by-slugs, /by-user/:userId, /user
VersionsGET / POST /v1/scripts/:id/versions, POST /v1/scripts/:id/versions/:versionNumber/restore
SharingPOST / DELETE /v1/scripts/:id/share-token, GET /v1/share/:token, POST /v1/scripts/:id/thumbnail
PinsGET /v1/me/pins, POST / DELETE /v1/scripts/:id/pin
Schemas (ER diagrams)POST / GET /v1/schemas, GET / PATCH / DELETE /v1/schemas/:id
WorkspacesPOST / GET /v1/workspaces, GET / PATCH / DELETE /v1/workspaces/:id, /public, /avatar, /transfer-ownership, members and invitations
LearningGET /v1/courses/:slug, GET /v1/scripts/:id/lesson-context, POST /v1/concepts/:slug/viewed, GET /v1/concepts/mastered
Notifications & remindersGET /v1/notifications, POST /v1/notifications/read-all, GET / PATCH /v1/reminders/schedule, /pause, /resume
AnalyticsPOST /v1/analytics/events, /funnel, GET /v1/analytics/dashboard, /popular, /recent, /script/:id/summary, /script/:id/daily
AIGET / POST /v1/ai/chat, POST /v1/ai/realtime/session, /usage
AccountPOST /v1/account/consents, GET / PATCH /v1/account/preferences, GET /v1/me/activity, /me/ai-suggestions
Admin (requires is_admin)everything under /v1/admin/* — scripts, users, courses, bundles, reports, emails, stats, LLM config, refunds — plus GET /v1/audit/events

Rate limits and quotas

Two layers of throttling apply:

  1. Per-key rate limit: 1000 requests per hour per API key. Exceeded requests are rejected by the auth layer.
  2. Per-user quotas: each plan tier has separate caps for daily publishes, daily drafts, hourly comments, and daily AI chat calls. See GET /v1/quota/me for the current windows. Exceeding a quota returns 429 quota_exceeded. See the pricing page for the per-tier numbers.

Errors

All errors are JSON. Common shapes:

StatusWhen
401Missing or invalid credentials.
403Authenticated but not allowed (wrong owner, non-admin route, non-public script).
404Resource not found, or hidden from this requester.
409Conflict, e.g. already_subscribed when starting a second subscription.
413payload_too_large — script code or comment exceeds the per-tier byte limit.
422Validation error. Body is a Zod issue list, or for topology scripts a structured DSL validation result.
429{ "error": "quota_exceeded", "quota": "<key>", "limit": <number> }.
503Provider unavailable (e.g. billing_not_configured, S3 not configured for thumbnails).

A typical Zod 422 response:

{
  "error": "validation_failed",
  "issues": [
    { "path": ["name"], "message": "String must contain at least 1 character(s)" }
  ]
}