Interactive API Consoles & Try-It Panels

This guide is part of Developer Portal Frameworks & UI Setup and covers the one component of a documentation portal that talks to a live API: the try-it console. It addresses the decisions that determine whether that console is useful and safe — how requests reach the API, where credentials live, which environment is selected by default, and what protects the API from a documentation page that anyone can drive. It does not cover the rendering of the reference itself; that belongs to the individual renderer guides such as Swagger UI Customization and Scalar & Modern Docs Integration.

A try-it console is the highest-value element on an API reference page and the highest-risk one. It compresses the gap between reading and a first successful call from hours to seconds, which is the metric most portals are actually trying to improve. It is also the only element that carries a credential, mutates state, and crosses an origin boundary — three properties that make every other page on the site look inert by comparison.

How console requests reach the API A console can call the API directly requiring CORS, route through a same-origin proxy, or call a mock server, each with different credential and network implications. try-it console docs.example.com direct call needs CORS on the API same-origin proxy /api-proxy/* — no CORS mock server no credential, no state the API sandbox or production

Quick reference: choosing a request path

Direct browser call Same-origin proxy Mock server
CORS configuration needed Yes, on the API No No
Works with a private/internal API No Yes Yes
Credential visible to the browser Yes No, if injected server-side None needed
Can mutate real data Yes Yes No
Rate limiting under your control No Yes N/A
Response fidelity Real Real Schema-accurate only
Infrastructure to run None A proxy service A mock service
Best for Public sandbox APIs Everything else Unreleased or destructive endpoints

The third column is under-used. For endpoints that are destructive, expensive, or not yet shipped, pointing the console at a mock generated from the same OpenAPI document gives readers a realistic response shape with no credential and no consequences — and it stays correct automatically, because it is generated from the contract. Mock Servers & Contract Testing from Specs covers generating that mock.

Prerequisites & Environment Setup

  • An OpenAPI document with a populated servers array. The console’s environment selector is built from it; a spec with a single hardcoded production URL gives readers no safe option.
  • A sandbox environment that accepts the same requests as production against disposable data.
  • A credential story: either a public demo key, a short-lived token endpoint, or a proxy that injects the credential server-side.
  • Decided CORS or proxy, before you ship — retrofitting a proxy after the console is live changes every request URL.

Confirm what the API currently allows before writing any console configuration. This is the check that predicts whether a direct call will work:

# Simulate the browser preflight the console will send from the docs origin
curl -si -X OPTIONS 'https://api.example.com/v1/invoices' \
  -H 'Origin: https://docs.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type' \
  | grep -i '^access-control-'

A working direct-call setup answers with all four of these:

access-control-allow-origin: https://docs.example.com
access-control-allow-methods: GET, POST, PATCH, DELETE, OPTIONS
access-control-allow-headers: authorization, content-type
access-control-max-age: 600

If any are missing — and in particular if access-control-allow-origin echoes * while you also need to send credentials, which the browser refuses to combine — you need the proxy path. Proxying CORS for API try-it consoles implements it.

Core Configuration

Environments belong in the spec, not in the console’s configuration, so that every renderer and every generated SDK sees the same list. Order matters: most consoles select the first entry by default, so the sandbox goes first.

# openapi/api.yaml
servers:
  - url: https://sandbox.api.example.com/v1
    description: Sandbox  disposable data, safe to call from the docs console
  - url: https://api.example.com/v1
    description: Production  real data, real charges

Then configure the console to honour that ordering and to keep credentials out of storage. For Scalar:

// scalar.config.js
export default {
  spec: { url: '/openapi/api.bundled.yaml' },
  // Never persist credentials — the console holds them for the session only.
  persistAuth: false,
  // Route every request through the docs origin; removes CORS entirely.
  proxyUrl: '/api-proxy',
  servers: {
    // Pin the default rather than relying on array order alone.
    default: 'https://sandbox.api.example.com/v1',
  },
  hideDownloadButton: false,
};

For Swagger UI, the equivalent shape plus an explicit guard against the console silently keeping keys:

window.ui = SwaggerUIBundle({
  url: '/openapi/api.bundled.yaml',
  dom_id: '#swagger-ui',
  // persistAuthorization writes credentials to localStorage — leave it off.
  persistAuthorization: false,
  // Rewrite the request through the same-origin proxy before it is sent.
  requestInterceptor: (req) => {
    const target = new URL(req.url);
    req.url = `/api-proxy${target.pathname}${target.search}`;
    return req;
  },
  // Surface a visible warning when the reader selects production.
  onComplete: () => {
    document.querySelector('.servers select')?.addEventListener('change', (e) => {
      const isProd = !e.target.value.includes('sandbox');
      document.getElementById('prod-warning').hidden = !isProd;
    });
  },
});

The requestInterceptor is the whole proxy integration on the client side: the console still believes it is calling the server from the spec, and the URL is rewritten to a same-origin path immediately before the request leaves. Because it is same-origin, the browser sends no preflight and applies no CORS check at all.

Integration Pattern

The proxy is a small, deliberately boring service. Its entire job is to forward a request to an allowlisted upstream, attach a credential the browser never sees, and enforce a rate limit. Everything it does not do is as important as what it does — it must not forward arbitrary hosts, and it must not accept a target URL from the client.

// api-proxy/index.js — deployed on the docs origin at /api-proxy/*
const ALLOWED = new Map([
  ['sandbox', 'https://sandbox.api.example.com/v1'],
  ['production', 'https://api.example.com/v1'],
]);

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const path = url.pathname.replace(/^\/api-proxy/, '');

    // The client picks an environment NAME, never a URL. This is the whole
    // difference between a proxy and an open redirector.
    const envName = url.searchParams.get('env') === 'production' ? 'production' : 'sandbox';
    const upstream = ALLOWED.get(envName);

    const headers = new Headers(request.headers);
    headers.delete('cookie');                 // never forward docs-site cookies upstream
    headers.set('origin', new URL(upstream).origin);

    // Sandbox requests use a server-side demo credential; production requires
    // the reader to supply their own, which we pass through untouched.
    if (envName === 'sandbox') {
      headers.set('authorization', `Bearer ${env.SANDBOX_DEMO_TOKEN}`);
    }

    const res = await fetch(`${upstream}${path}${url.search}`, {
      method: request.method,
      headers,
      body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
      redirect: 'manual',                     // do not follow upstream redirects blindly
    });

    // Strip upstream CORS headers; same-origin responses do not need them.
    const out = new Headers(res.headers);
    out.delete('access-control-allow-origin');
    out.set('cache-control', 'no-store');
    return new Response(res.body, {status: res.status, headers: out});
  },
};

Two properties make this safe. The client sends an environment name that is looked up in a fixed map, so no request can reach a host you did not list — a proxy that accepts a full target URL is an open relay that will be found and abused. And the sandbox credential is attached server-side, so readers get a working console with no signup and no key in the page.

Credential placement by configuration A demo token injected by the proxy never reaches the browser; a reader-supplied token stays in memory for the session; a key in localStorage persists and is the configuration to avoid. where does the credential live? proxy-injected server-side secret browser never sees it works with no signup best for sandbox in-memory, per session reader pastes a token gone when the tab closes not in any storage best for production localStorage persistAuthorization survives the tab readable by any script do not ship this

Advanced Options

Warn before a production call, and make it hard to do by accident. The console should require an explicit environment switch and show an unmissable indicator while production is selected. Colour alone is not enough — it fails for colour-blind readers and in dark mode:

[data-console-env="production"] .console-panel {
  outline: 3px solid #b1352f;
  outline-offset: 2px;
}
[data-console-env="production"] .console-panel::before {
  content: "⚠ Production — requests affect real data";
  display: block;
  padding: 0.5rem 0.75rem;
  font-weight: 700;
}

Rate-limit at the proxy. A documentation page is reachable by crawlers, scanners, and anyone who wants to script it. Because the proxy holds the sandbox credential, an unlimited proxy is a free, unauthenticated gateway to your sandbox. Limit per IP and per session, and return a 429 with a Retry-After the console can display.

Prefer short-lived tokens over API keys. If the console needs a real credential, mint a token scoped to read-only sandbox operations with a lifetime measured in minutes. A token that expires quickly turns a leaked screenshot from an incident into a non-event. Injecting API keys safely into docs consoles covers the minting endpoint and the storage rules in detail.

Hide operations that should not be callable. Internal, deprecated, or destructive endpoints do not belong in a public console even when they belong in the reference. Filter them from the spec the console loads, rather than hiding them in CSS — a hidden element is still in the document and still callable. Hiding internal endpoints in Swagger UI shows the build-time filter.

Make the console reproduce as code. The most useful thing a console can do after a successful request is hand the reader something they can paste into a terminal or a project. Every mainstream console can emit a curl invocation; the ones worth configuring also emit a snippet in the languages you publish SDKs for, generated from the same OpenAPI document as the SDKs themselves. Wire the snippet generator to your actual client libraries rather than to a generic HTTP template, so the copied code uses the method names a reader will find in the SDK — client.invoices.list() rather than a raw fetch against a URL. This is the point where the portal and SDK Generation & Changelog Automation meet, and keeping the two consistent removes a whole category of “the docs show something different from the library” confusion.

Decide what a failed request should teach. A console that renders a raw 422 body has technically done its job and practically wasted the moment. The reader is at the exact point of confusion where a good error explanation lands best, so surface the response’s validation detail prominently, link the specific field that failed to its entry in the schema table on the same page, and show the request that produced it rather than making the reader reconstruct it. If your API returns a structured problem document, render it as fields rather than as JSON — the console is the one place in the portal where you know precisely what the reader was trying to do.

Budget for the console in your performance work. Try-it panels are heavier than anything else on a reference page: a request builder, a JSON viewer, a syntax highlighter, and often a schema-driven form generator. On a large specification this can dominate the page’s JavaScript. Load the console lazily on interaction rather than at page load, so readers who are only reading pay nothing for a feature they do not use, and so the reference page’s Largest Contentful Paint reflects the documentation rather than the tooling. A console that appears a quarter-second after a click is indistinguishable from one that was always there; a reference page that takes three seconds to become interactive is not.

Treat the console as part of the API’s public surface. Anything the console can do, an arbitrary visitor can do — including scripted visitors. That means the sandbox behind it needs the same rate limiting, quota, and abuse monitoring as any other public entry point, and the demo credential it carries needs rotation on a schedule rather than only after an incident. The mental shift that avoids most trouble here is to stop thinking of the console as a documentation feature and start thinking of it as an unauthenticated client of your API that happens to be embedded in a documentation page.

Verification & Testing

Test the console the way a reader uses it — in a real browser, against the deployed site, in both themes.

node -e "
const { chromium } = require('playwright');
(async () => {
  const b = await chromium.launch();
  const p = await b.newPage();
  const failures = [];
  p.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });

  await p.goto('https://docs.example.com/api/invoices/list/');
  await p.click('[data-testid=\"try-it\"]');
  await p.click('[data-testid=\"send\"]');
  await p.waitForSelector('[data-testid=\"response-status\"]');

  const status = await p.textContent('[data-testid=\"response-status\"]');
  const env    = await p.getAttribute('[data-console-env]', 'data-console-env');
  console.log('status:', status.trim(), '| env:', env);
  if (failures.length) { console.error('console errors:', failures); process.exit(1); }
  if (env !== 'sandbox') { console.error('default environment is not sandbox'); process.exit(1); }
  await b.close();
})();
"

Expected output from a healthy console:

status: 200 OK | env: sandbox

A CORS failure shows up here as an empty response plus a console error mentioning Access-Control-Allow-Origin, which is why the script fails on any console error rather than only on a non-200. Run this against the preview URL in CI, as described in Docs CI/CD & Preview Deployments, so a CORS regression on the API side is caught by the docs pipeline rather than by a reader.

Also assert the negative: that no credential reached storage.

// Should print an empty array. Anything else is a finding.
Object.keys(localStorage).filter(k => /auth|token|key|bearer/i.test(k));

The sequence below is what a single console request actually does once the proxy is in place. Reading it in order is the fastest way to locate a failure, because each hop fails distinctively:

Console request sequence The console posts to a same-origin path, the proxy resolves the environment name, attaches the credential and forwards upstream, then strips CORS headers from the response. console /api-proxy the API same-origin — no preflight env resolved, credential attached upstream response CORS stripped, no-store set the browser never learns the upstream origin, and never sees the credential

Troubleshooting

TypeError: Failed to fetch with no status code. The browser blocked the request before it was sent, or the preflight failed. Check the network panel for an OPTIONS request — if it is present and returned a non-2xx, the API is not answering preflight; if it is absent, the request was blocked by a Content Security Policy connect-src directive on the docs site rather than by CORS.

Requests succeed but the response body is empty. The API allowed the origin but did not expose the headers the console reads, or the response is opaque because the request was sent in no-cors mode by a misconfigured client. Add Access-Control-Expose-Headers for anything the console displays, such as rate-limit headers.

The console works for GET and fails for POST. Simple GET requests can skip preflight entirely, so a missing Access-Control-Allow-Methods only surfaces on the first non-simple request. Test with POST and a JSON content type, not with GET.

Readers report 401 on the sandbox that they cannot fix. The server-side demo credential expired. Because it is injected by the proxy, no reader action can resolve it and the failure looks like a broken console rather than an expired key. Monitor the demo credential’s expiry as infrastructure, and have the proxy return a distinguishable error body when its own credential is rejected.

A reader mutated production data from the docs. The default environment was production, or the switch was too easy. This is a design failure rather than a bug: reorder servers so sandbox is first, pin the default explicitly, require an interaction to switch, and consider making write operations mock-backed for anyone who has not authenticated.

FAQ

Why does the try-it console fail with a CORS error only in production?

The API’s Access-Control-Allow-Origin list includes localhost from development but not the deployed documentation origin. The console is a normal browser request from the docs domain, so the API must allow that exact origin including scheme and port, and must answer the preflight OPTIONS request with the methods and headers the console sends.

Is it safe to let the console store an API key?

Only in memory for the session, never in localStorage. A key in localStorage survives the tab, is readable by any script on the origin, and is routinely captured in screenshots attached to bug reports. Prefer a short-lived token minted for the console over a long-lived key.

Should the console call production or a sandbox by default?

Default to a sandbox environment and make production an explicit, deliberate selection. A console that defaults to production turns a documentation page into a way to mutate real data by accident, which is the single most damaging failure mode of a try-it panel.

Do I need a proxy for the try-it console?

You need one whenever you cannot change the API’s CORS policy, the API is not reachable from the public internet, or you want credentials to stay server-side. A same-origin proxy on the docs domain removes CORS entirely and gives you one place to enforce rate limits and audit usage.