Skip to content

API-Only Apps

Most Vulcan apps are full-stack — they pair a backend Worker with a frontend UI. But sometimes you want a backend with no UI at all: a JSON service that other tools call over HTTP. Vulcan has first-class support for this. You get a Worker, a Vulcan-managed API key, an automatically published OpenAPI 3.0 spec, and CORS — without writing any of it yourself.

This page walks through what you get, how authentication works, and how to call the resulting API.


When you'd build one

  • A microservice consumed by another Vulcan app, a script, or a SaaS automation.
  • An integration endpoint for a webhook or a third-party tool that needs to push data into Veho.
  • A backing service for a custom UI (mobile app, internal dashboard) that lives outside Vulcan.

If your end users will visit the URL in a browser and click around, you want a regular app — not an API-only app.


Creating an API-only app

Two ways to bootstrap one:

Pick the api template

When you create a new project, pick the api template from the template picker. You'll get a working REST API on first build with:

  • Five editable files (src/api/data.ts, src/api/handlers.ts, src/api/spec.ts, and the standard frontend trio).
  • A KV-backed CRUD "items" resource as a starting point — list, get, create, update, delete.
  • The api-key-auth component already wired into handlers.ts.
  • A self-describing docs page at GET / and an interactive playground for testing endpoints.
  • An OpenAPI 3.0 spec served at GET /api/openapi.json.

The companion template is graphql-api — same shape, but with GraphQL resolvers and a built-in GraphiQL explorer at GET /graphql instead of REST.

Or just describe it in chat

If api doesn't match what you want, skip the template and describe the API in the chat:

"Build me a REST API for managing inventory items — list, get, create, update, delete. Use KV for storage."

"I need a webhook receiver that accepts POSTs from Stripe and forwards them to a Slack channel."

The AI will scaffold the same pattern (handlers, auth, OpenAPI spec) starting from your description.


Authentication: X-API-Key

API-only apps authenticate callers with a single header — X-API-Key. There is no Vulcan session involved; any external client with the key can call your endpoints.

How the key is managed

You don't set the key yourself. Vulcan generates a secure 64-character key at deploy time and stores its SHA-256 hash as a Cloudflare Secret called INBOUND_API_KEY_HASH. This is managed by Nox, Vulcan's deployment service. Do not call set_secret('INBOUND_API_KEY_HASH') — Nox owns it.

When a call comes in, your handler hashes the inbound X-API-Key, compares it to the stored hash, and returns 401 if it doesn't match. The plaintext key never lives on the server.

Where you see the key

The first time you deploy, the plaintext key is shown once in the chat. Copy it immediately — Vulcan won't show it again. If you lose it, the only path forward is to rotate it.

The key is also auto-populated in your app's GET / docs page and playground when you view that page while signed into Vulcan. External visitors to the same URL see an empty key field and have to paste it manually.

Rotating the key

Ask the AI to rotate:

"Rotate my API key."

"Regenerate my inbound API key — I lost the old one."

The AI will call its rotate_api_key tool, which queues a rotation for the next deploy. After the deploy completes, the new plaintext key is shown once at the top of the next chat response. Save it.

After rotation, the old key stops working immediately — anyone calling your API with it gets a 401. Update every caller (curl scripts, other services, etc.) before rotating.

What if you've never deployed?

Rotation only works against a deployed app — Nox needs a Worker to write the secret to. Deploy at least once first; then rotate.


The OpenAPI spec

Every API-only app from the api template exposes its OpenAPI 3.0 spec at:

GET /api/openapi.json

The spec describes every endpoint, request/response schema, and the auth scheme — so external tools (Postman, Insomnia, OpenAPI codegen, MCP server connectors) can import your API definition with one click.

Editing the spec

There's no hand-edited spec file. The api template uses @hono/zod-openapi — schemas live in src/api/schemas.ts (zod types tagged with .openapi(...)) and routes are declared via createRoute({ method, path, request, responses }) in src/api/handlers.ts. The spec at /api/openapi.json is generated at request time from those declarations.

When you add or rename an endpoint, just add a createRoute declaration paired with app.openapi(route, handler) (or registerRoute if you want scope gating — see below). The spec re-derives automatically. Ask the AI:

"Add a /api/items/search endpoint to handlers.ts."

The server URL is injected dynamically per request, so the spec always reflects the actual deployment URL — preview, production, custom domain, all auto-resolved.

Who can read the spec

The OpenAPI endpoint is gated. To fetch it you need either:

  • A valid Vulcan session (Veho employees signed into the IDE — the spec opens directly in your browser).
  • A valid X-API-Key header (external tools).

This keeps the schema out of the public internet while still letting your callers introspect the API. The same gating applies to the GraphQL schema endpoints if you used the graphql-api template.


Calling your API

Once deployed, your API is reachable at the URL Vulcan assigned to it — visible in the IDE's deploy panel and in the URL the AI mentions after the first successful deploy. Typically it looks like https://<your-app>.nyx.shipveho.com.

A minimal curl call:

bash
curl -H "X-API-Key: <your-key>" https://<your-app>.nyx.shipveho.com/api/items

Creating an item:

bash
curl -X POST https://<your-app>.nyx.shipveho.com/api/items \
  -H "X-API-Key: <your-key>" \
  -H "Content-Type: application/json" \
  -d '{"name": "widget", "description": "a useful thing"}'

Fetching the OpenAPI spec:

bash
curl -H "X-API-Key: <your-key>" https://<your-app>.nyx.shipveho.com/api/openapi.json

The /api/health endpoint is intentionally public (no X-API-Key required) so external monitoring can probe liveness.


CORS

If you need a browser-based caller to hit your API directly, configure CORS by setting the CORS_ALLOWED_ORIGINS environment variable. Examples:

  • * — allow any origin (only do this for genuinely public APIs).
  • https://myapp.com — allow exactly one origin.
  • https://a.com,https://b.com — allow a comma-separated list.

Ask the AI to set it:

"Set CORS_ALLOWED_ORIGINS to https://my-frontend.example.com on this app."

The api template already handles preflight (OPTIONS) requests correctly and emits the Vary: Origin header when you're not using a wildcard.


The GraphQL alternative

The graphql-api template follows the same model with a GraphQL twist:

  • Resolvers live in src/api/resolvers.ts, schema in src/api/schema.ts.
  • Built-in GraphiQL explorer at GET /graphql for interactive testing.
  • Same X-API-Key auth pattern as the REST template.
  • Schema endpoints (/graphql/schema.json, /graphql/schema.graphql) are gated behind the same session-or-key check used by /api/openapi.json.

Use it when your callers prefer typed GraphQL queries; use the REST template when you want maximum compatibility with curl, Postman, and webhook-style integrations.


What you don't get

API-only apps are public in the sense that anyone with the API key can call them — there's no Vulcan session, no Google login, no partner allowlist gating the request. Important consequences:

  • Treat the API key as a secret. Don't paste it into a public repo, a screenshot, or a shared channel.
  • Rotate immediately if it leaks. See the rotation steps above.
  • Don't use API-only apps for @shipveho.com-only data unless you trust every holder of the key. If you need per-user identity, build a regular Vulcan app with a frontend, or use Partner Access for a passkey-gated experience.

Per-key scopes

You can mint multiple keys per app and give each one a different set of scopes. Routes gated by a vulcanScopes field reject keys that don't carry the required scope:

ts
const DeleteItemRoute = createRoute({
  method: 'delete',
  path: '/api/items/{id}',
  // Only keys with 'items:write' OR 'admin:*' can call this
  vulcanScopes: ['items:write'],
  // ...
});
registerRoute(DeleteItemRoute, async (c) => { /* handler */ });

Routes without vulcanScopes stay open to any valid key.

Wildcards. A stored admin:* scope satisfies any required scope that starts with admin: (and matches the literal admin:* itself). Other wildcards are intentionally not supported — keep scopes flat.

Backward compatible. Keys minted before scopes shipped have no scopes field and pass every check. To enforce scopes, mint new keys with explicit scope arrays.

Mint a scoped key from chat. Ask: "Mint a key called Acme integration with scopes items:read" — the chat will pass the scopes array to mint_api_key.

403 envelope. Failing the check returns the standard envelope with code: 'INSUFFICIENT_SCOPE' and details: { required, provided } so consumers see what they're missing.


Auto-generated API documentation

Three platform-managed routes let external consumers onboard without you sharing files manually:

  • GET /api/docs — Swagger UI rendered from a pinned swagger-ui-dist CDN build. Visit the URL in a browser to explore endpoints interactively.
  • GET /api/postman.json — Postman v2.1 collection. In Postman: Import → URL → paste this URL. Every endpoint shows up as a runnable request, with the X-API-Key header pre-filled as a variable.
  • GET /api/client.ts — a TypeScript ApiClient class with one method per endpoint. Drop the file into a TypeScript project and import { ApiClient } from './client'. Compiles clean with tsc — parameter and response types are intentionally unknown (a type-precise generator is a follow-up).

All three are gated like /api/openapi.json: a logged-in Vulcan session OR a valid X-API-Key lets them through. Point a partner at /api/docs first — it's the fastest path to a working call.


Idempotency

Mutating endpoints (POST / PUT / PATCH / DELETE) support an Idempotency-Key header so clients can safely retry without double-creating, double-charging, or double-sending. The first call runs the handler; subsequent calls within 24 hours with the same key replay the cached response.

bash
curl -X POST https://my-app.example.com/api/items \
  -H "X-API-Key: vk_abc123_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"name":"Widget"}'

# Retry the SAME request with the same Idempotency-Key — server replays:
curl -X POST https://my-app.example.com/api/items \
  -H "X-API-Key: vk_abc123_..." \
  -H "Idempotency-Key: <SAME-AS-ABOVE>" \
  -H "Content-Type: application/json" \
  -d '{"name":"Widget"}'
# Response carries: X-Idempotency-Replay: true

What's cached. Status code, body, and content-related headers. Set-Cookie, Date, and X-RateLimit-* are stripped from the replay so per-request state doesn't leak.

What's NOT cached. Responses with status 500 or higher (they may be transient — retries should hit the handler again).

Bucket. vulcan:idem:{kid}:{METHOD}:{path}:{idempotency-key} in KV. Different consumers using the same key don't collide; reusing a key across different paths doesn't replay.

Skip rules. Idempotency only applies to POST / PUT / PATCH / DELETE — GET/HEAD/OPTIONS are already safe to retry by HTTP definition. Requests without the header pass through normally.

Already-deployed apps. The helpers ship with the immutable platform layer (src/api/_platform.ts), so an existing api app picks up support on its next deploy. The canonical wiring is in the template's POST /api/items handler — copy that pattern for any new mutating route the AI generates.


Webhook signing & verification

If your API sends webhooks to customers, or receives webhooks from third parties, use the canonical helpers from ./_platform instead of hand-rolling HMAC. They follow the Stripe/Svix convention so existing receiver libraries port with minimal tweaks.

Outbound — sending a webhook to a customer:

ts
import { signWebhook } from './_platform';

const body = JSON.stringify(event);
const { signature, timestamp } = await signWebhook(body, env.WEBHOOK_SECRET);
await fetch(target, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Vulcan-Signature': signature,
    'X-Vulcan-Timestamp': timestamp,
  },
  body,
});

Inbound — receiving a webhook:

ts
import { verifyWebhook, apiError } from './_platform';

app.post('/api/inbound', async (c) => {
  const body = await c.req.raw.text();   // raw bytes — NOT JSON.stringify(JSON.parse(body))
  const result = await verifyWebhook(c.req.raw, body, c.env.WEBHOOK_SECRET);
  if (!result.ok) return apiError('INVALID_SIGNATURE', result.reason, 401);
  const event = JSON.parse(body);
  // ...process event...
  return c.json({ ok: true });
});

The verifier returns one of these structured reasons on failure: missing-signature, missing-timestamp, invalid-timestamp, timestamp-out-of-range (default replay window is 5 minutes), or signature-mismatch. Pass the reason to apiError so failures are diagnosable in logs without leaking which check tripped.

Critical: pass the raw request body bytes to verifyWebhook. Re-stringifying after JSON.parse(body) produces different bytes (whitespace, key order) and the HMAC will fail. Always read via await request.text() BEFORE parsing.


Rate-limit headers

Every response from a deployed api or graphql-api app — both success and 429 — carries three headers:

  • X-RateLimit-Limit — total request budget per minute
  • X-RateLimit-Remaining — what's left in the current window
  • X-RateLimit-Reset — Unix timestamp when the window resets

Clients should self-throttle when Remaining gets low instead of waiting for a 429. The default budget is 60 req/min per kid (per API key for authenticated calls; per IP for anonymous calls). Override via RATE_LIMIT_RPM env var.

When you do hit 429, the response also carries Retry-After plus the same VBE-96 envelope ({ error: { code: 'RATE_LIMITED', message, requestId } }).


External IdP authentication

If a partner runs their own identity provider (Auth0, Cognito, Okta, or a custom OIDC server) and wants to send JWTs instead of using your API key, install the jwt-bearer component. It verifies inbound Authorization: Bearer <jwt> headers against the issuer's JWKS, validates standard claims, and exposes the verified payload on c.var.jwt so handlers can read the sub, scopes, and any custom claims.

The component:

  • Supports RS256 / RS384 / RS512 and ES256 / ES384 / ES512 — asymmetric algorithms only. HS256 is rejected by design.
  • Caches the IdP's JWKS per-isolate for 10 minutes, so steady-state verification adds zero network round-trips.
  • Extracts scopes from both scope (space-separated string, OAuth standard) and scp (array, Microsoft/Okta convention).
  • Integrates with the same scope check used by API Key Auth — a JWT carrying items:read in its claims passes a route declared with vulcanScopes: ['items:read'], exactly like an API key would.

Required env vars: JWT_ISSUER (e.g. https://your-tenant.auth0.com/) and JWT_AUDIENCE (your API's identifier). JWT_JWKS_URI is optional and defaults to ${JWT_ISSUER}.well-known/jwks.json.

For a self-issued login flow (your worker mints the JWT after a username/password check), use the older jwt-auth component instead — that one is HMAC-SHA256 and intended for app-managed identity, not partner SSO.


Reference

  • Template sourcevulcan-ide/src/templates/app-templates/api.ts (REST) and graphql-api.ts.
  • Immutable platform layervulcan-ide/src/templates/base-scaffold.ts (renders the src/api/_platform.ts shipped with every app — owns auth, scopes, idempotency, webhook helpers, rate limiting, audit log, usage stats, docs endpoints).
  • Componentsapi-key-auth (Vulcan-managed key), jwt-auth (self-issued HS256), jwt-bearer (external-IdP verification).
  • Toolsrotate_api_key, mint_api_key({label, scopes?}), revoke_api_key({kid, immediate?}), use_template('api' | 'graphql-api').
  • Routes worth knowingGET / (docs page), GET /api/health (public liveness probe), GET /api/openapi.json (gated spec), GET /api/docs (Swagger UI), GET /api/postman.json, GET /api/client.ts, POST /api/keys/rotate / mint / revoke (gated key management — usually via chat tools).

Built by the Veho Developer Platform team