The recommended client. Drop these three files into your project, set two environment variables, and you can call any endpoint with full type safety. For a step-by-step explanation of each piece, see Cookbook: TypeScript client generation.

Files

src/fd/types.ts — generated

npx openapi-typescript https://docs.future-demand.com/api-reference/openapi.json \
  -o src/fd/types.ts
Regenerate nightly in CI. See the CI workflow snippet.

src/fd/session.ts — sign-in + refresh

const BASE = process.env.FD_BASE_URL!;
const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;

export type Session = {
  accessToken:  string;
  refreshToken: string;
  expiresAt:    number;   // ms epoch
  partnerId:    string;
};

let session: Session | null = null;
let refreshPromise: Promise<Session> | null = null;

export async function signIn(
  username: string,
  password: string,
  partnerId: string,
): Promise<Session> {
  const r = await fetch(`${BASE}/auth`, {
    method:  "POST",
    headers: { "Content-Type": "application/json" },
    body:    JSON.stringify({ username, password }),
  });
  if (!r.ok) throw new Error(`Sign-in failed: ${r.status}`);
  const data = await r.json();
  if (!data.AccessToken)
    throw new Error(`Sign-in challenge: ${data.Challenge ?? "unknown"}`);

  session = {
    accessToken:  data.AccessToken,
    refreshToken: data.RefreshToken,
    expiresAt:    Date.now() + (data.ExpiresIn ?? 3600) * 1000,
    partnerId,
  };
  return session;
}

async function refresh(): Promise<Session> {
  if (!session) throw new Error("No session");
  const r = await fetch(`${BASE}/auth/refresh_token`, {
    method:  "POST",
    headers: { "Content-Type": "application/json" },
    body:    JSON.stringify({ token: session.refreshToken }),
  });
  if (!r.ok) throw new Error(`Refresh failed: ${r.status}`);
  const data = await r.json();
  session = {
    ...session,
    accessToken: data.AccessToken,
    expiresAt:   Date.now() + (data.ExpiresIn ?? 3600) * 1000,
  };
  return session;
}

export async function getSession(): Promise<Session> {
  if (!session) throw new Error("Not signed in");
  if (session.expiresAt - Date.now() > REFRESH_THRESHOLD_MS) return session;
  refreshPromise ??= refresh().finally(() => { refreshPromise = null; });
  return refreshPromise;
}

export function setPartnerId(partnerId: string) {
  if (!session) throw new Error("Not signed in");
  session = { ...session, partnerId };
}

export function clearSession() { session = null; refreshPromise = null; }

src/fd/client.ts — the typed client

import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./types";
import { getSession, clearSession } from "./session";

const BASE = process.env.FD_BASE_URL!;

const authMiddleware: Middleware = {
  async onRequest({ request }) {
    const s = await getSession();
    request.headers.set("Authorization", `Bearer ${s.accessToken}`);
    request.headers.set("X-Preferred-Partner-Id", s.partnerId);
    return request;
  },
  async onResponse({ response }) {
    if (response.status === 401) clearSession();
    return response;
  },
};

const authMiddleware401: Middleware = {
  async onResponse({ response }) {
    if (response.status === 401) {
      clearSession();
      onUnauthenticated?.();   // your re-auth / redirect hook (see below)
    }
    return response;
  },
};

export const fd = createClient<paths>({ baseUrl: BASE });
fd.use(authMiddleware);
fd.use(authMiddleware401);

// Optional: a second client for the CR API host
export const fdCr = createClient<any>({ baseUrl: process.env.FD_CR_BASE_URL! });
fdCr.use(authMiddleware);
Don’t retry inside openapi-fetch middleware. A naive fetch(request.url, ...) retry bypasses the rest of the middleware chain and can’t safely re-use an already-consumed request body. Put retry in a standalone helper instead — see Rate limits: exponential backoff — and wrap individual calls with it:
const { data } = await withBackoff(() =>
  fd.GET("/events/", { params: { query: { limit: 5 } } }),
);
A readable retry predicate:
const retryable = response.status === 429 || response.status >= 500;
if (!retryable) return response;
onUnauthenticated extension point. SDK consumers usually need to hook 401 handling into their own re-auth / redirect flow. Expose a callback rather than hard-coding a redirect:
let onUnauthenticated: (() => void) | undefined;
export function setOnUnauthenticated(fn: () => void) { onUnauthenticated = fn; }

Usage

import { fd, signIn } from "./fd";

await signIn("[email protected]", "...", "partner-id-acme");

// Strongly typed — autocomplete on path, query, response
const { data: events, error } = await fd.GET("/events/", {
  params: { query: { limit: 5, descending: false } },
});

const { data: tcs } = await fd.GET("/events/{id}/suggested_tcs", {
  params: {
    path:  { id: events!.items[0].id },
    query: { budget: 5000, goal: "UTILIZATION" },   // UTILIZATION | ROAS | VISIBILITY | LINK_CLICKS
  },
});

Server-side variant

In a Node server you’ll typically:
  1. Sign in once at startup with a service account.
  2. Run a background timer that refreshes ~5 min before expiry.
  3. Pass partnerId per request — do not mutate a global.
Never call a global setPartnerId(...) per request. With concurrent requests, a global mutable partner id is a race condition: request A’s partner can leak into request B’s call. Pass the partner id explicitly to each call (a thin per-request wrapper), or scope it with AsyncLocalStorage.
import { signIn, getSession } from "./fd/session";
import createClient from "openapi-fetch";
import type { paths } from "./fd/types";

// One service-account session manages the token (its partnerId is unused
// server-side — we override the partner header per request below).
await signIn(SVC_USER, SVC_PASS, "any-partner");
setInterval(() => getSession().catch(console.error), 60_000);

// A per-request client bound to the caller's partner — no global mutation.
function clientFor(partnerId: string) {
  const c = createClient<paths>({ baseUrl: process.env.FD_BASE_URL! });
  c.use({
    async onRequest({ request }) {
      const { accessToken } = await getSession();          // shared token, refreshed
      request.headers.set("Authorization", `Bearer ${accessToken}`);
      request.headers.set("X-Preferred-Partner-Id", partnerId);   // per-request
      return request;
    },
  });
  return c;
}

app.get("/my-events", async (req, res) => {
  const fd = clientFor(req.user.partnerId);     // scoped per request
  const { data } = await fd.GET("/events/", { params: { query: { limit: 20 } } });
  res.json(data);
});

Caveats

With concurrent server requests, a global mutable partner id races. Use a per-request client (above) or AsyncLocalStorage.
Don’t retry inside openapi-fetch middleware — it bypasses the chain and can re-use a consumed body. Wrap calls with a standalone backoff helper (see Rate limits).
For some Cognito flows, POST /auth/refresh_token accepts device_key and device_group_key alongside token. The minimal { token } body works for the common case, but be ready to pass the device fields if your account uses remembered-device MFA.
signIn above throws on Challenge. For interactive flows, handle the challenge response and call PUT /auth/challenge/mfa-token. See Authentication.