← Back to blog
Tutorial · 10 min read

Build a Chrome Extension That Calls the OpenAI API

Use AI inside your extension safely: where to store the API key, how to call OpenAI from a service worker, and how to handle rate limits.

Illustration for the ManifestGo article: Build a Chrome Extension That Calls the OpenAI API

One of the most popular Chrome extension categories in 2026 is 'AI-powered' — summarizers, rewriters, translators, code explainers. Almost all of them call an LLM API from inside the extension.

Where to store the API key

  • Never hardcode it in the manifest or source files — anyone can unzip your extension.
  • Use `chrome.storage.local` for user-supplied keys (each user pastes their own).
  • Use a backend proxy for keys you own — your extension calls your server, your server calls OpenAI.
  • ManifestGo's secrets vault handles the proxy automatically.

Calling OpenAI from a service worker

Service workers can use `fetch` directly — no need for content scripts or message passing for the network call itself. The pattern is: content script captures user selection → posts to service worker → service worker calls OpenAI → returns the response.

Handling rate limits and errors

  • Wrap every fetch in try/catch — network errors are the most common failure mode.
  • Respect 429 responses with exponential backoff.
  • Show a clear error in the popup, not a silent failure.

Exact fetch pattern from a service worker

A minimal working call looks like: fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + apiKey }, body: JSON.stringify({ model: "gpt-4o-mini", messages: [...] }) }). Because MV3 service workers can't use XMLHttpRequest, fetch with async/await inside a chrome.runtime.onMessage listener is the standard pattern, and the listener must return true to keep the message channel open for the async response.

The host_permissions entry OpenAI calls require

Add "https://api.openai.com/*" to host_permissions in manifest.json. Without it, fetch throws "Failed to fetch" with no CORS detail in the service worker console, which is a frequently misdiagnosed error — developers assume it's an OpenAI-side CORS block when it's actually a missing manifest permission.

Message-passing between content script and service worker

  1. Content script captures the selection with window.getSelection().toString() and sends it via chrome.runtime.sendMessage({ type: "SUMMARIZE", text }).
  2. Service worker's chrome.runtime.onMessage listener checks message.type, calls the OpenAI API, and calls sendResponse(result) — but only after returning true synchronously to keep the channel alive.
  3. For popup-to-worker communication instead of content-script, the same sendMessage/onMessage pattern applies; only the sender context differs.
  4. For streaming responses, use a chrome.runtime.Port connection instead of one-shot messages, since sendMessage doesn't support incremental chunks well.

Rate limits and the exact status codes to handle

  • 429 Too Many Requests — back off exponentially starting around 1 second, doubling up to a capped max, and read the Retry-After header if present.
  • 401 Unauthorized — the API key is invalid, revoked, or missing the Bearer prefix; surface this distinctly from a network error so users know to re-enter their key.
  • 500/503 — OpenAI-side outage; retry once after a short delay, then show a clear 'service unavailable, try again' message rather than looping silently.
  • Context length errors (400 with a message mentioning maximum context length) — truncate the input text client-side before resending rather than failing outright.

Why a backend proxy beats a client-stored key for most products

If you're paying for API usage yourself (rather than each user bringing their own key), never ship your key inside the extension — unzipping any Chrome extension reveals every string in its source, including a hardcoded key. Instead, route calls through a small backend you control that holds the real OpenAI key server-side and enforces per-user quotas; the extension only ever talks to your backend over HTTPS.

If your API key would embarrass you on a public GitHub repo, it shouldn't be sitting in an unzipped Chrome extension either — both are equally readable.

CSP considerations for API-calling extensions

The default extension_pages CSP (script-src 'self'; object-src 'self') doesn't restrict fetch destinations — that's controlled by host_permissions, not CSP. But if your popup renders API responses as HTML (say, Markdown-rendered summaries), you must sanitize before inserting into the DOM, since the CSP won't stop a script-injection XSS from within your own rendered content.

Frequently asked questions

Why does my OpenAI call work in a regular webpage but fail from the extension's service worker?

Service workers require the API's origin to be explicitly listed in host_permissions in manifest.json; a webpage isn't bound by that manifest, which is why the same fetch call behaves differently in each context.

Can I stream OpenAI's response token-by-token into a Chrome extension popup?

Yes, using the OpenAI streaming API with server-sent events, but you need to relay chunks from the service worker to the popup via a chrome.runtime.Port rather than a single sendMessage/sendResponse pair.

Does using chrome.storage.session help with API costs?

Indirectly — caching recent API responses in chrome.storage.session (which clears when the browser closes) avoids re-calling the API for the same input during a single browsing session, reducing redundant paid requests.

Keep reading