VendoVendo Docs
ReferenceHTTP API

Deploy

Trigger a deployment of a catalog tool into the calling tenant.

POST /api/deploy

Provisions a deployment of a catalog tool into the calling user's tenant. This is what the deploy wizard calls when the user clicks "Launch". It is session-authenticated only — deployed tools and external SDKs cannot deploy on a tenant's behalf.

Auth

Session cookie (vendo.run dashboard). No Bearer token form.

Pre-flight checks

Before provisioning, the endpoint enforces:

  • The session belongs to an authenticated user (401 otherwise).
  • The user owns the target tenant (tenants.owner_id) — 403 otherwise.
  • The user has already chosen a workspace slug (slug_chosen).
  • The billing gate is satisfied (skipped for test tenants and Vendo admins).
  • The tool is enabled in the catalog and has an active release (enabled = false rejects with 403 { "error": "tool_unreleased" } for non-admin callers).
  • The desired deploymentSlug is unique within the tenant and the rendered {tenant_slug}-{deployment_slug}.vendo.run subdomain doesn't collide with another live deployment.
  • Every provider in bindings[] matches an enabled row in the integrations catalog.
  • Every required integration in the tool's release manifest has a caller-supplied connection or a vendo_managed_pool fallback profile.

Request body

{
  "toolSlug": "hermes",                    // required, max 100 chars
  "tenantSlug": "acme",                    // required, max 100 chars
  "deploymentSlug": "support-bot",         // optional, max 63 chars; defaults to defaultDeploymentSlugForTool(toolSlug)
  "deploymentName": "Support Bot",         // optional, max 100 chars; blanks collapse to undefined
  "adminPassword": "…",                    // optional, max 200 chars — only for tools that seed an admin user
  "userVariables": {                       // optional — wizard inputs declared in the tool's manifest
    "TIMEZONE": "America/Los_Angeles"      // values: string (max 10000 chars), number, or boolean
  },
  "bindings": ["openrouter", "telegram"],  // optional — provider slugs the integration picker opted into
  "selectedBindings": {                    // optional — provider_slug → connection UUID
    "openrouter": "00000000-0000-0000-0000-000000000000"
  },
  "pendingBindings": {                     // optional — provider_slug → inline-BYOK payload
    "telegram": { "bot_token": "…" }       // forwarded to the integration's inlineConnect() to validate + persist
  },
  "userInput": { }                         // optional — values referenced as ${userInput.<field>} in afterDeploy hooks
}

Only toolSlug and tenantSlug are required. bindings, selectedBindings, userVariables are zod-validated. pendingBindings and userInput are passed through without schema validation — only the provider's inlineConnect() and the hook engine sees them.

Idempotency-Key HTTP header is honored: repeat calls with the same key replay the cached response. The idempotency scope is keyed on {tenant_id}:/api/deploy plus the request body.

Response: 201 Created

{ "deploymentId": "dpl_…" }

Use the returned id with /api/deployments/{id}/… endpoints to poll status, restart, suspend, or destroy.

Errors

Bodies are flat — no error wrapper. Each row below is { "message": "…", "code": "…", … } at the listed HTTP status.

StatuscodeWhen
400(none — Zod field errors)Request body fails DeployBodySchema.safeParse; body has { message: "Invalid request body", errors: { fieldName: [...] } }
400INVALID_SLUGdeploymentSlug fails validateDeploymentSlug
400SUBDOMAIN_TOO_LONG{tenant_slug}-{deployment_slug} exceeds the 63-char DNS-label limit
400SUBDOMAIN_RESERVEDRendered subdomain hits the reserved-host blocklist
400SLUG_REQUIREDTenant has never chosen a workspace slug (slug_chosen = false)
400UNKNOWN_BINDINGAny slug in bindings[] is not an enabled integrations row; body includes unknown: string[]
400MISSING_BINDINGA required provider has no caller-supplied credential AND its supported profiles exclude vendo_managed_pool; body includes missing: string[]
400no_inline_connect (string error)pendingBindings named a provider whose integration has no inlineConnect() implementation
400{provider}_connect_failed (string error)The integration's inlineConnect() threw
401(no code)No session — body is { "message": "Unauthorized" }
402BILLING_NOT_SETUPTenant has no Stripe customer row
402INSUFFICIENT_CREDITSWallet balance ≤ 0; body includes balance_usd: number
403(no code)Tenant owner mismatch — body is { "message": "Tenant not found or not authorized" }
403tool_unreleased (string error)Tool's enabled = false and caller is not an admin
404(no code)toolSlug doesn't exist in apps_catalog
409SLUG_TAKENAnother live deployment in this tenant uses the same deploymentSlug
409SUBDOMAIN_TAKENThe rendered subdomain collides with a live deployment in any tenant
429(no code)Per-IP deployLimiter exceeded; body is { "message": "Rate limit exceeded" }
500(no code)Vendo bug; body is { "message": "Internal server error", "detail": "..." }
502WORKER_UNREACHABLECouldn't reach the Cloudflare Workflows deploy worker; body includes detail: string
502(no code)Deploy worker returned non-2xx; body is { "message": "Deploy worker failed to start deployment", "detail": "<status> <body>" }

Example error body:

{
  "message": "A deployment already exists at acme-support-bot.vendo.run. Pick a different slug.",
  "code": "SLUG_TAKEN"
}

Rate limit

deployLimiter: 5 requests per hour per IP. Bursts beyond return 429 with body { "message": "Rate limit exceeded" }.

This endpoint provisions real infrastructure (Railway service, domain, Postgres, …) and may run for tens of seconds. The handler itself is wrapped in withIdempotency: repeated POSTs with the same Idempotency-Key HTTP header return the cached response. Without an idempotency key, retry only after fetching the user's deployment list — a transient 5xx may have left a successful deployment row behind.

POST /api/connections/bind-deployment

Called by the connect-portal pages after a connection lands when the originating URL carried ?deployment_id=<id>. Writes the app_connection_bindings row that links a new connection to the deployment that asked for it. Idempotent on (app_id, provider_slug).

Auth

Session cookie. The caller's tenant must own both the deployment and the connection.

Request body

{
  "deploymentId": "dpl_…",
  "providerSlug": "openrouter",
  "connectionId": "00000000-0000-0000-0000-000000000000"
}

All three fields are required strings.

Response

Success:

{ "ok": true }

Telegram bindings that have a webhook setup failure return 200 with a soft warning:

{ "ok": true, "webhook_warning": "..." }

Conflict on an "exclusive" provider (e.g. Telegram bot tokens) — 409:

{
  "ok": false,
  "reason": "connection_in_use",
  "bound_to": { "deployment_slug": "other", "deployment_name": "Other" }
}

Errors

Error bodies use the { ok: false, reason: "..." } shape:

StatusreasonWhen
400bad_requestMissing or non-string deploymentId / providerSlug / connectionId
400provider_mismatchResolved connection's provider_slug doesn't match the body's providerSlug
401unauthorizedNo session
404no_tenantSigned-in user has no production tenant
404connection_not_foundConnection id doesn't exist or isn't owned by this tenant
404deployment_not_found(from bindConnectionToDeployment helper)
400deployment_has_no_appDeployment row has no authorization_id
409connection_in_useExclusive connection already bound to another live deployment; body includes bound_to: { deployment_slug, deployment_name }
400(raw DB error message)Underlying postgres insert error from the helper

Owner-only deployment management

The rest of the per-deployment surface lives under /api/deployments/{id}/… and is session-authenticated (owner only). These are dashboard-only — not part of the SDK or CLI contract — and beyond the scope of this page. Notable ones:

EndpointDescription
GET /api/deployments/{id}Current state
POST /api/deployments/{id}/restartRestart the container
POST /api/deployments/{id}/suspendPause (preserves data)
POST /api/deployments/{id}/resumeResume from suspended
POST /api/deployments/{id}/destroyTear down (irreversible)
GET /api/deployments/{id}/usageHourly cost rollup
POST /api/deployments/{id}/renameChange deployment slug
POST /api/deployments/{id}/rollbackRoll back to a previous template version
POST /api/deployments/{id}/retryRetry a failed deploy

On this page