OAuth worked example
A complete TypeScript implementation of the authorization-code flow — every step from install link to verified webhook, with the mistakes worth avoiding.
The OAuth reference tells you what the endpoints do. This walks through a complete implementation, and flags the places that are easy to get wrong.
Contributed by Epic Design Labs, from building the first third-party app on the Foundry marketplace. We’ve kept their notes on what tripped them up, and corrected a few points where their reading of our docs was reasonable but wrong — those corrections are marked. Where they got caught, our documentation usually deserved the blame; the gaps they hit are fixed on the pages this one links to.
Everything here uses standard Web APIs — fetch and Web Crypto — so it runs unchanged on Node 18+, Cloudflare Workers, Deno and Bun. The HTTP handlers use Hono because it’s small, but none of the logic depends on it.
Before you write any code
You need two things from Foundry, and both are granted rather than requested:
- Developer access, which also provisions your sandbox.
- A
client_idandclient_secret. The secret is shown once and is not recoverable.
You also register your redirect URI, which lives on your app version.
Settle your production hostname before you ask for credentials. Redirect URIs are matched byte-for-byte and live on the version, so changing one means submitting a new version — and every existing install keeps its old version until the merchant re-authorizes, so both URIs have to keep working until they do. Epic deployed to a real domain first, then asked for credentials.
The shape of it
1. You → send the merchant to GET /oauth/authorize
2. They → approve a consent screen listing exactly what you asked for
3. They → get redirected back to you with ?code=…&state=…
4. You → POST /oauth/token (server-to-server, with your secret)
5. You → receive a scoped access token
6. You → register your own webhook endpoint ← easy to miss
Steps 1–3 happen in the merchant’s browser. Steps 4–6 are server-to-server and your client_secret must never leave your backend.
Step 1 — send the merchant to authorize
const FOUNDRY_API = "https://api.foundryims.com/api/v1";
/**
* The exact origin you registered. No trailing slash, and it must match the
* registered string character for character — `/cb` and `/cb/` are different URIs.
*/
const APP_BASE_URL = "https://yourapp.example.com";
const REDIRECT_URI = `${APP_BASE_URL}/oauth/callback`;
/** Request the narrowest set that works. Every one is shown on the consent screen. */
const SCOPES = [
"products.read",
"products.write",
"variants.read",
"variants.write",
"webhooks.manage",
] as const;
/** URL-safe, unguessable. 32 bytes is plenty. */
function randomState(): string {
const buf = crypto.getRandomValues(new Uint8Array(32));
return btoa(String.fromCharCode(...buf))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
app.get("/oauth/install", async (c) => {
const state = randomState();
// Persist it server-side, tied to this browser session, with a short TTL.
// A signed cookie works too — the requirement is that you can prove the
// callback came from a flow *you* started.
await db.saveOAuthState({
state,
redirectUri: REDIRECT_URI,
expiresAt: Date.now() + 10 * 60_000,
});
const url = new URL(`${FOUNDRY_API}/oauth/authorize`);
url.searchParams.set("client_id", process.env.FOUNDRY_CLIENT_ID!);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", SCOPES.join(" "));
url.searchParams.set("state", state);
return c.redirect(url.toString(), 302);
});
stateis required. The OAuth spec permits omitting it; Foundry doesn’t. Without it an attacker can trick a merchant into authorizing your app into an org of the attacker’s choosing. Generate it unguessably, tie it to the session that started the flow, and reject any callback that doesn’t match.
Omitting scope entirely is legal and means “everything this version was approved for”. Naming scopes explicitly is better: it fails loudly with invalid_scope if you ask for something your version doesn’t declare, instead of quietly granting a set you didn’t think about.
Step 2 — handle the callback
The ordering here matters more than it looks. Validate state before you read anything else, including before you handle an error — until state checks out, the entire query string is attacker-controlled.
app.get("/oauth/callback", async (c) => {
const url = new URL(c.req.url);
const state = url.searchParams.get("state");
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
if (!state) return c.redirect("/?error=missing_state", 302);
const stored = await db.takeOAuthState(state); // read AND delete: single-use
if (!stored) return c.redirect("/?error=invalid_state", 302);
if (stored.expiresAt < Date.now()) {
return c.redirect("/?error=expired_state", 302);
}
// Only now is the rest of the query string trustworthy.
if (error) {
// `access_denied` means the merchant declined. That's a choice, not a fault —
// say so plainly rather than showing them a stack trace.
const detail = url.searchParams.get("error_description") ?? "";
return c.redirect(
`/?error=${encodeURIComponent(error)}&detail=${encodeURIComponent(detail)}`,
302,
);
}
if (!code) return c.redirect("/?error=missing_code", 302);
// …exchange it (step 3)
});
Delete the stored state whether or not the rest succeeds. A callback that can be replayed is a callback an attacker can replay.
Step 3 — exchange the code for a token
const tokenRes = await fetch(`${FOUNDRY_API}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
code,
client_id: process.env.FOUNDRY_CLIENT_ID,
client_secret: process.env.FOUNDRY_CLIENT_SECRET,
// Must be the same string you sent in step 1. Foundry checks it.
redirect_uri: stored.redirectUri,
}),
});
if (!tokenRes.ok) {
const body = (await tokenRes.json().catch(() => ({}))) as {
error?: string;
error_description?: string;
};
// See the error table below — these are deliberately non-specific.
return c.redirect(
`/?error=${encodeURIComponent(body.error ?? "token_exchange_failed")}`,
302,
);
}
const token = (await tokenRes.json()) as {
access_token: string; // fims_…
token_type: "Bearer";
scope: string; // space-separated, the scopes actually granted
org_id: string; // the organization that consented
};
Correction to the contributed text. Epic’s draft warned that the token endpoint takes JSON rather than form encoding, and called it the thing they most second-guessed. Both work:
application/jsonandapplication/x-www-form-urlencodedare parsed identically, so the standard OAuth form-encoded request behaves exactly as the spec says it should. Use whichever your HTTP client makes natural. The examples here use JSON because the rest of the Foundry API does.
The code is single-use and expires in 10 minutes. Exchange it immediately; don’t queue it.
Step 4 — know which organization you’re holding
org_id on the token response is authoritative, and needs no extra request:
const orgId = token.org_id;
If you need to identify the org on a token you already hold — one loaded from your database, say, rather than one you just minted — GET /orgs/me requires no particular scope, so it works on any live key regardless of what the merchant granted:
const res = await fetch(`${FOUNDRY_API}/orgs/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`Could not identify the organization (${res.status})`);
const { id } = (await res.json()) as { id: string };
Don’t infer the org from catalog data. Epic first read
orgIdoff the first record ofGET /products?limit=1. That returns nothing for an org with an empty catalog — a perfectly plausible state for a merchant installing a catalog tool before importing their catalog. Their fallback synthesised a key from the token, which quietly broke re-authorization: it created a second installation row instead of overwriting the first.
Step 5 — store the token, keyed by organization
await db.upsertInstallation({
foundryOrgId: orgId,
accessToken: await encrypt(token.access_token), // it's a live API key
grantedScopes: token.scope,
appVersion: MY_APP_VERSION,
});
Four properties of the token that shape how you store it:
| No expiry, no refresh token | It’s an ordinary Foundry API key. Nothing silently dies after 30 days, and there is no refresh flow to implement. |
| Re-authorizing revokes the previous one immediately | So upsert, always. If you only write on first install, a merchant who reconnects leaves you holding a dead credential while you believe you’re connected. |
| It carries only the approved scopes | Record grantedScopes. See the 403 note below. |
| It’s revocable | The merchant can revoke it from the admin, and uninstalling revokes it. |
Encrypt it at rest. It’s a live key with whatever write access the merchant granted; a database snapshot leaking shouldn’t hand over their catalog.
Record the app version too
Existing installs keep the version they consented to. So if you publish a new version that requests an extra scope, that scope will 403 on every install that hasn’t re-authorized yet — possibly for a long time.
Store the version and the granted scopes per install, then degrade rather than assume:
const missing = SCOPES.filter((s) => !install.grantedScopes.includes(s));
if (missing.length > 0) {
// Tell the merchant what re-authorizing would unlock, instead of letting a
// feature fail with a bare error later.
}
Step 6 — register your own webhook endpoint
An OAuth install provisions no webhook endpoint. This surprises people, and it’s the step most likely to be missed. A manual install asks the merchant for a webhook URL; in an OAuth flow there’s nobody to ask, because the endpoint belongs to you rather than to them.
const hook = await api<{ id: string; secret: string }>("/webhooks", {
method: "POST",
body: {
// All three are required. `name` is what the merchant sees in their admin,
// so name it after your app, not after the flow.
name: "Acme Catalog Sync",
url: `${APP_BASE_URL}/webhooks/foundry/${install.id}`,
events: ["product.created", "product.updated", "import.completed"],
},
});
// The signing secret is returned exactly once. Store it now or rotate it later.
await db.saveWebhookEndpoint({
installationId: install.id,
foundryWebhookId: hook.id,
secret: await encrypt(hook.secret),
});
Correction to the contributed text. Epic’s draft fetched
GET /webhooks/eventsfirst and filtered their subscription against it. Two problems. That endpoint returns{ "events": [...] }, not a bare array or a{ data }envelope, so their parse produced an empty set and would have subscribed to nothing. And the filtering is unnecessary: an unrecognised event name is rejected with a400naming the bad event and listing every valid one. Subscribe to what you want and read the error. Our webhooks page told them to validate defensively without documenting the response shape — that was our bug, and it’s fixed.
Two things to get right in the event list:
- Include at least one event your submitted version declares. The webhook-ping submission check looks for an endpoint in your sandbox subscribed to an event your app version asks for. Register only events outside that set and the check can’t find your endpoint.
import.completedis not optional if you care about catalog changes. Import-driven changes deliberately don’t emit per-product events, so an app watching onlyproduct.createdandproduct.updatedsilently misses every CSV and supplier-feed import — probably the most common way a catalog actually changes.
Step 7 — verify webhook signatures
/**
* Header: `x-foundry-signature: t=<unix>,v1=<hex hmac sha256>`
* Signed over `{t}.{rawBody}` with the endpoint's secret.
*/
export async function verifySignature(
header: string | null,
rawBody: string,
secret: string,
toleranceSeconds = 300,
): Promise<boolean> {
if (!header) return false;
const t = header.match(/t=(\d+)/)?.[1];
const received = header.match(/v1=([0-9a-f]+)/)?.[1];
if (!t || !received) return false;
// Reject stale timestamps to blunt replay.
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const mac = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(`${t}.${rawBody}`),
);
const expected = [...new Uint8Array(mac)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (expected.length !== received.length) return false;
// Constant-time compare. Web Crypto has no timingSafeEqual, so XOR the whole
// string rather than returning early on the first mismatched character —
// an early return leaks the position of the difference.
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ received.charCodeAt(i);
}
return diff === 0;
}
rawBodymust be the exact bytes you received. Re-serializing parsed JSON changes key order and whitespace, which changes the digest. Many frameworks parse the body for you before your handler runs — if yours does, you must explicitly capture the raw body. This is a silent failure: nothing errors, the signature simply never matches, and it will fail the webhook-ping submission check with no obvious cause.
In Hono, await c.req.text() gives you the raw body:
app.post("/webhooks/foundry/:installationId", async (c) => {
const rawBody = await c.req.text(); // raw, not c.req.json()
const endpoint = await db.getWebhookEndpoint(c.req.param("installationId"));
if (!endpoint) return c.json({ error: "unknown endpoint" }, 404);
const ok = await verifySignature(
c.req.header("x-foundry-signature") ?? null,
rawBody,
await decrypt(endpoint.secret),
);
if (!ok) return c.json({ error: "invalid signature" }, 401);
const deliveryId = c.req.header("x-foundry-delivery-id")!;
// A delivery is attempted up to 8 times over roughly two days, and replays
// are operator-triggerable, so the same id legitimately arrives more than
// once. A duplicate is a SUCCESS — returning non-2xx counts against the
// five-strike auto-disable below.
if (await db.haveSeenDelivery(deliveryId)) {
return c.json({ ok: true, deduplicated: true });
}
await db.recordDelivery(deliveryId, rawBody);
// Do NOT process inline. Enqueue and return.
await queue.send({ deliveryId, installationId: c.req.param("installationId") });
return c.json({ ok: true });
});
Respond 2xx within 10 seconds, and an endpoint that fails five consecutive deliveries is auto-disabled. If your handler does real work — calling another API, running a sync — a slow dependency can silently disable your webhook for every merchant at once. Verify, dedup, persist, enqueue, return.
One thing to know about the end of an install’s life: revoking your token does not stop webhook deliveries. The endpoint you registered belongs to the merchant’s org and outlives the credential, and once your key is revoked you can’t delete it yourself. Treat an install you’ve marked dead as dead — ignore deliveries for it rather than acting on them.
Error handling
At the OAuth endpoints
Errors that occur before Foundry trusts your redirect_uri are rendered in place as JSON, because redirecting a browser to an unvalidated URI is how open redirects are built. Everything after that redirects back to you with the error in the query string, carrying your state.
| Code | Meaning |
|---|---|
invalid_request | Missing parameter, or redirect_uri doesn’t match a registered one |
invalid_client | Unknown client_id, or client auth failed at the token endpoint |
invalid_grant | Code is unknown, expired, already used, issued to a different client, or paired with a different redirect_uri |
invalid_scope | You asked for a scope your approved version doesn’t declare |
unsupported_response_type | response_type wasn’t code |
unsupported_grant_type | grant_type wasn’t authorization_code |
access_denied | The merchant declined |
These are deliberately non-specific. “No such client” and “wrong secret” both return invalid_client; “expired code” and “already used” both return invalid_grant. That’s so the endpoints can’t be used to enumerate valid client IDs or confirm a code was ever real. Don’t build error messages that assume more detail than you’re given.
On API calls
Three status codes that must be handled differently:
if (res.status === 401) {
// Token is dead: revoked, uninstalled, or superseded by a re-authorization.
// Mark the installation and ask the merchant to reconnect. Retrying never helps.
await db.markNeedsReconnect(install.id);
throw new AuthError();
}
if (res.status === 403) {
// Valid token, missing scope. Usually means this install consented to an
// earlier version of your app. Retrying never helps — degrade, and tell the
// merchant what re-authorizing would unlock.
throw new ScopeError(requiredScope);
}
if (res.status === 429) {
// Retry-After is on every 429 and is the simplest thing to obey.
const retryAfter = Number(res.headers.get("retry-after") ?? 1);
await sleep(retryAfter * 1000);
// …then retry
}
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds), and every 429 additionally carries Retry-After. The documented limit is 300/min per key, but it’s counted per serving instance, so the effective ceiling is somewhat higher and varies with how many are running — pace off the response headers rather than modelling the limit. A token bucket hardcoded to exactly 300 will be wrong in both directions.
Testing it before you submit
Run the whole flow against your sandbox. Two of the five submission checks depend on your having done so:
- Sandbox exercise — one successful authenticated request on the token your sandbox connection issued.
- Webhook ping — Foundry sends a signed test delivery to your registered endpoint and requires a 2xx. It’s signed exactly as a production delivery, so a wrong verification fails the check. You get one retry, so a cold serverless endpoint won’t cost you a rejection.
The order is: connect your sandbox → register your webhook endpoint → submit. Your endpoint URL doesn’t exist until you have an installation to register it for.
Until your version is approved, only your own sandbox may consent. Any other organization attempting the flow gets a 403. The restriction lifts on approval — so this is expected behaviour, not a misconfiguration.
The short list
Everything above, as a checklist:
-
stategenerated unguessably, tied to the session, single-use, verified first -
redirect_uribyte-identical at both legs and to what you registered - Code exchanged immediately — single-use, 10 minute TTL
- Org read from
org_idon the token response, never inferred from catalog contents - Token upserted per org on every exchange, not just first install
- Token encrypted at rest
-
grantedScopesand app version recorded per install - Webhook endpoint registered by you — OAuth provisions none
- Webhook
name,urlandeventsall sent; events overlap what your version declares - Webhook signing secret stored on creation; it’s shown once
-
import.completedsubscribed if you care about catalog changes - Signature verified over raw received bytes, constant-time, with timestamp tolerance
- Webhook handler enqueues and returns; nothing slow inline
- Deliveries deduped on
X-Foundry-Delivery-Id, duplicates answered 2xx - 401 → reconnect, 403 → degrade, 429 → obey
Retry-After - Whole flow exercised against your sandbox before submitting
One more, outside the OAuth flow
If your app writes third-party licensed data into a merchant’s catalog and that catalog feeds a public storefront, that’s redistribution — and it’s usually licensed separately from internal use. Worth confirming your data agreement covers it before a merchant depends on you, rather than after.
Related
- Connecting an app with OAuth — the endpoint reference this example implements
- Publishing an app — sandbox, submission, and the five checks
- Webhook events — the full event catalog and delivery semantics
- Authentication & API keys — the scope model behind
403