Skip to content
Guides— browse docs
On this page

External APIs and secrets

An api/ folder gives your creation a server half — and config.secrets is where the API key lives, because process.env is empty.

Let's say you're building a tool that pulls creature art from a service you pay for. The call needs an API key, and the key can't live in your tool's code — a bundle is a file anyone who installs your creation can read. It can't live in an environment variable either, because process.env is empty inside a creation.

So your creation grows a server half: an api/ folder. Each file in it is one endpoint. It runs on the platform, never enters your browser bundle, and your UI calls it as if it were a local function.

You will learn

  • How to add a server endpoint your browser bundle never sees
  • How config decides which secrets and which hosts an endpoint can reach
  • How to call your own endpoints as typed functions, with no fetch and no URL
  • Where your API key actually lives — and why process.env can't hold it

The folder is the configuration

Add an api/ folder and the build turns on a second, server-side bundle. There is nothing to switch on and nothing to configure.

bestiary/
├── src/
│   └── tool.tsx                the browser half
└── api/
    ├── fetch-art.ts            → fetch-art
    └── webhooks/stripe.ts      → webhooks/stripe

The file path is the route. That's the whole routing story.

An endpoint

api/fetch-art.ts
import { defineHandler } from "@vvd/sdk/server"

export const config = {
  secrets: ["ART_API_KEY"], // UPPER_SNAKE_CASE, the env-var convention
  fetch: ["art.example.com"], // outbound allowlist — exact hostnames
}

export default defineHandler(async (ctx, { creature }: { creature: string }) => {
  const key = await ctx.secrets.get("ART_API_KEY") // decrypted server-side only
  const res = await ctx.fetch(
    `https://art.example.com/v1/search?q=${encodeURIComponent(creature)}`,
    { headers: { authorization: `Bearer ${key}` } },
  )
  if (!res.ok) return { ok: false as const, status: res.status }
  const { images } = await res.json()
  return { ok: true as const, url: images[0]?.url ?? null } // a plain value — JSON-marshalled for you
})

Two things are doing more work than they look like they are.

config is enforcement, not documentation. ctx.secrets.get resolves only names listed in config.secrets. ctx.fetch reaches only hostnames listed in config.fetch — exact, lowercase, no scheme, no path, no wildcards; art.example.com does not cover cdn.art.example.com. Undeclared means unreachable, and the error names the line to add — so the top of the file is the complete set of things this endpoint can touch.

ctx is the only way out. There is no ambient fetch and no process.env. Every capability an endpoint has arrives on ctx, assembled from the config above it.

Calling it from your UI

Your client calls its own endpoints as typed functions. No fetch, no JSON plumbing, no URL, and — importantly — no tool id.

src/tool.tsx
import { useApi } from "@vvd/sdk"

interface ArtApi {
  fetchArt(args: { creature: string }): Promise<{ ok: boolean; url?: string | null }>
}

const api = useApi<ArtApi>()
const { url } = await api.fetchArt({ creature: "Ashfall Drake" })

Method names map camelCase to the kebab-case file path: fetchArt → api/fetch-art.ts. Every method also takes a trailing { signal } for cancellation, and each method's function identity is stable across renders, so it's safe in a dependency array. The typed client sends JSON in a body, so defineHandler endpoints answer POST and nothing else — a GET gets a 405 saying so, and reaches raw handlers only.

Warning:

The tool id is bound by the host, per mount. useApi() doesn't take one — the host that mounted your tool put its id into the capability, so a tool physically cannot call another tool's endpoints through this door. That's a security property, not a convenience.

It works because the host provides it

The tool below calls useApi() exactly the way yours will — but this page has no server, so the six lines marked as the stand-in wire the server capability to a function instead of the dispatch route. The component doesn't know the difference: swap the host, swap what a call means, change no tool code. Press both buttons — the second hits a handler that throws.

useApi, with the host supplying the server capability
src/tool.tsx

Edit and the example re-runs. Tab indents; press Escape to leave the editor.

Running · your edits, live
Starting the example…

When a call fails

Failures reject as WorkshopApiError, carrying code, status and details — catch it and show err.message. A throw inside your handler comes back sanitised: you saw the exact wording in the demo above. The real error goes to the server log; the browser learns which endpoint failed and nothing else, because an exception can easily contain a URL, a header, or a fragment of a key. Debugging therefore happens in the logs — use ctx.log(...) liberally.

A host with no server capability at all — a published page, an anonymous reader — fails loud at the boundary. Gate the affordance rather than declaring it in needs:

src/tool.tsx
const host = useHost()
return host.server ? <FetchArtButton /> : null

Webhooks and the raw form

When a caller needs the metal — verifying a signature over the exact request bytes, streaming a response — opt into the raw form, the Next.js shape exported directly:

api/webhooks/stripe.ts
import type { ServerContext } from "@vvd/sdk/server"

export const config = { raw: true, secrets: ["STRIPE_WEBHOOK_SECRET"] }

export async function POST(req: Request, ctx: ServerContext) {
  const signature = req.headers.get("stripe-signature")
  const body = await req.text() // signature verification needs the raw bytes
  const secret = await ctx.secrets.get("STRIPE_WEBHOOK_SECRET")
  // verify `signature` over `body` with `secret` using crypto.subtle…
  ctx.log("stripe webhook", { verified: true })
  return Response.json({ received: true })
}

Raw handlers get the untouched Request, may export POST and/or GET, and are HTTP-only: they exist for external callers, so useApi has no typed caller for them — or for any nested path, which is almost always a webhook.

What ctx gives you

ctx.*What it is
secrets.get(name)The decrypted value of a declared secret — this creation's only
fetch(url, init)Outbound HTTP(S), allowlisted to your declared hosts
identityWho called: { userId, kind } where kind is auth, guest or anon — a resolved principal, never a raw token
scopeThe world (and optionally project) the call is scoped to
log(…)Structured logs to your dashboard; fire-and-forget, never throws
index.generate(req)The World Index — declare config.index first; the deep dive is under Tools

There is deliberately no ctx.documents in v1 — read world data from the client, where the grant system already works.

Node built-ins are a build error

The server runtime is isolate-shaped: Web-standard APIs (fetch, Request/Response, crypto.subtle, URL, TextEncoder) plus ctx. No filesystem, no processes, no native addons — and you find out at build time, with an error naming the specifier and the Web-standard alternative:

Server code can't use "node:fs": the isolate runtime has no filesystem — state goes through ctx. (Full-Node needs route to the container tier later.)

This is why the webhook example above verifies its signature with crypto.subtle rather than node:crypto.

Where your API key lives

Every instinct built up over a decade of Node says: put the key in an environment variable, read process.env.MY_KEY, move on. Do that here and you get undefined — no error, no warning — and you spend the next hour convinced the key is wrong.

Warning:

process.env is empty inside a creation. Not discouraged — empty. In server code (api/) it is literally {}; in client code (src/) it's a tiny stub holding a couple of the host's own public values. No mechanism puts your variables into it, in dev or in production. Secrets arrive through config.secrets and await ctx.secrets.get(), and nothing else.

There's a design reason. A creation is a distributable artifact: the same bytes run in your dev session and on a stranger's machine after they install it. There are no environments to set variables for — there are installs — so configuration can't ride ambient process state; it has to be handed to a specific piece of code, on purpose.

That leaves exactly two channels:

What you haveWhere it goes
A secret — an API key, a webhook signing secret, anything a browser must never holdconfig.secrets: ["NAME"] on an endpoint + await ctx.secrets.get("NAME") — server handlers only
Non-secret configuration — a default, a mode, a label, a colourYour creation's own data: a field in the document, or (for apps) a declared customization parameter

Storing a secret

From your project folder:

vvd secret set ART_API_KEY sk-live-8f2b
Expected output:
✓ ART_API_KEY set (dev)
encrypted, this-tool-only — read it in api/ with ctx.secrets.get("ART_API_KEY").

Leave the value off and the CLI prompts for it, keeping it out of your shell history (it echoes as you type, though — on a shared screen, pipe it in). Names are UPPER_SNAKE_CASE, the same rule config.secrets enforces; anything else is rejected before it leaves your machine.

The read path is the endpoint at the top of this page. The declaration is the permission: an endpoint can't read a name it didn't declare, and a creation can't reach another creation's values at all.

Write-only, and loud when it's missing

After vvd secret set, nothing reads the value back — there is no vvd secret reveal. The only path to plaintext is ctx.secrets.get, inside a declared handler of yours, at call time. What you can see is which names exist and whether each has a value:

vvd secret list
Expected output:
  ● ART_API_KEY   set
○ SEARCH_TOKEN   not set

values are write-only — update one with vvd secret set <NAME>

A declared name with no stored value fails immediately and names the fix, rather than sending an empty Authorization header and letting someone else's 401 be your error message:

secrets: "ART_API_KEY" is declared but has no stored value — set it (e.g. `vvd secret set ART_API_KEY …`) before calling this endpoint.

Never put one in src/

Warning:

A secret in client code ships to every browser that opens your creation — your bundle is a file the platform serves, and no minification, obfuscation or NEXT_PUBLIC_ convention changes that. If your UI needs the result of using a key, call the handler and return the result. That's what the server half is for.

Rotating, removing, and --scope

Secrets are resolved at call time: setting a name again replaces the value, live on the next request, for every version of your creation at once. Rotating a leaked key needs no rebuild or re-save, and it reaches people who installed an older version. To remove a name, use the Workshop's Environment Variables panel — write-only, exactly like the CLI, and despite the name it has nothing to do with process.env.

Two honest caveats. --scope dev|published is recorded but not enforced yet — a creation has one value per name, shared by vvd run and your published version, so if you need a separate testing key, get one from the upstream provider. And --scope user — bring-your-own-key — isn't available yet, and says so.

Non-secret configuration

A default label, a display mode, an accent colour — these are ordinary data, and they belong with your creation's data. In a tool, that's a field in your codec — it syncs, undoes, and is per-document like everything else (Storing data). In an app, declare a customization block in vvd.json and the platform renders a Customize panel, with values arriving through useAppCustomization() (Theming). Constants stay constants in your source.

Testing a handler

createFakeServerContext() is the createFakeHost() twin: a complete, dependency-free ServerContext made of inert fakes. Seed the secrets, run the handler, assert on what it did — no dispatch route, no network, no server.

api/fetch-art.test.ts
import { createFakeServerContext } from "@vvd/sdk/server"
import fetchArt from "./fetch-art"

const ctx = createFakeServerContext({ secretValues: { ART_API_KEY: "k-123" } })
const result = await fetchArt(ctx, { creature: "Ashfall Drake" })

expect(ctx.fetch.calls[0].url).toContain("art.example.com")
expect(ctx.fetch.calls[0].init?.headers).toMatchObject({ authorization: "Bearer k-123" })

ctx.fetch records every call and answers 200 {}; ctx.log.entries records every log line; ctx.secrets.get throws for any name you didn't seed — so you find out you forgot to declare one before your users do.

The trust boundary, honestly

Your own vvd run session executes your handlers in-process — it's your code, called by you. A published third-party package runs in a workerd isolate, handed only that creation's own secrets, with every outbound request — ctx.fetch or a raw globalThis.fetch — routed through a gateway that only forwards declared hosts. The same defineHandler runs either way, but dev is more permissive: a handler that reaches for globalThis.fetch can work under vvd run and then fail once installed with EGRESS_DENIED. Declare the host and use ctx.fetch from day one.

The dev loop

api/ is part of vvd run: edit an endpoint and it rebuilds and pushes exactly like a src/ edit. One wrinkle that looks like a broken build: the folder is watched only if it existed when vvd run started. Create api/ mid-session and nothing happens — restart vvd run and it picks it up.

When it doesn't work

Your handler says the key is "not found", but you definitely set it. Nine times out of ten it's process.env. Replace each use with await ctx.secrets.get("NAME") plus a config.secrets entry.

secrets: "ART_API_KEY" is not declared. The endpoint's config.secrets doesn't list it. The message includes the list it does have, which usually shows you the typo.

secrets: "ART_API_KEY" is declared but has no stored value. Nothing stored under that exact name. Run vvd secret list — names are case-sensitive.

fetch: host "…" is not in this handler's egress allowlist. Matching is exact and there are no subdomain wildcards. Add the hostname. A relative URL fails too — ctx.fetch is outbound egress, so pass a full https:// URL.

Host capability "server" is not provided by this host. A surface with no server — a published page, or a test. Check host.server before you render the affordance.

You set a secret and your teammate's install still fails. Secrets are per-creation, not per-install — this is not the version pin. Check vvd secret list from the project folder, signed in as the account that owns the creation.

Recap

  • An api/ folder gives your creation a server half. Each file is one endpoint, and the path is the route.
  • config.secrets and config.fetch are enforcement: undeclared is unreachable, and the error tells you which line to add.
  • useApi() calls your own endpoints as typed functions. The host binds the tool id, so a tool cannot reach another tool's endpoints.
  • process.env is empty inside a creation. Secrets live in config.secrets + await ctx.secrets.get(), server-side only, write-only once set, and rotation is live on the next request.
  • Handler throws come back sanitised, and createFakeServerContext() tests a handler with no server at all.

Next steps