API Client Package

Typed browser client for the offer API, consumed by both apps

Overview

@zooly/api-client is the single place where "talk to the server" lives. Every screen in both zooly-app and zooly-app2 calls through it instead of raw fetch. It handles login cookies, adds a retry-safe key on writes, and returns a typed Result<T> that makes error handling obvious at the call site.

It is paired with @zooly/contracts — every request/response shape comes from the contracts package so the browser and the server can never disagree.

Package Details

  • Package Name: @zooly/api-client
  • Location: packages/api-client
  • Type: Isomorphic (browser + SSR)
  • Runtime dependencies: @zooly/contracts, @zooly/types

Creating a client

import { createOffersApi } from "@zooly/api-client";

// Same-origin: for calls from zooly-app's own screens to its own API
const api = createOffersApi();

// Cross-origin: for zooly-app2 (port 3007) calling zooly-app (port 3004)
const api = createOffersApi({
  baseUrl: import.meta.env.VITE_APP_URL, // e.g. "http://localhost:3004"
});

Options:

OptionDefaultPurpose
baseUrl"" (same-origin)API host. Required when the client is running on a different origin than the API
idempotencyKeycrypto.randomUUIDGenerator for the Idempotency-Key header. Override in tests or SSR environments without crypto.randomUUID
fetchglobalThis.fetchFetch impl. Override for testing

Every request is sent with credentials: "include" so the auth cookie travels cross-origin.

The Result<T> envelope

Every API call returns a discriminated union — no exceptions thrown for HTTP-level errors:

type Ok<T>  = { ok: true;  data: T; status: number };
type Err    = {
  ok: false;
  error: string;
  code: ErrorCode;
  status: number;
  details?: Record<string, unknown>;
};
type Result<T> = Ok<T> | Err;

Usage:

const r = await api.submitOffer({
  slug: "alex",
  campaignDescription: "...",
  campaignType: "ai_voice_over",
  offerAmountMinorUnit: 5000,
  campaignTypes: [],
  usageTypes: [],
});

if (!r.ok) {
  if (r.code === "RateLimited") {
    toast.error(`Daily limit: ${r.details?.currentCount}/${r.details?.limit}`);
  } else {
    toast.error(r.error);
  }
  return;
}

router.push(`/offers/${r.data.offerId}`);

Network failures (fetch rejection) surface as { ok: false, code: "InternalError", status: 0 }.

Available functions

As of Phase B (W1-01), the client covers the six hot offer-related endpoints:

FunctionMethodEndpointIdempotent
listNotifications(query?)GET/api/notifications/list
listOffers(query?)GET/api/offers/list
getOffer(id)GET/api/offers/:id
submitOffer(body)POST/api/offers/submityes
respondToOffer(body)POST/api/offers/respondyes
createPaymentIntent(body)POST/api/payments/create-intentyes

Writes automatically attach Idempotency-Key. A double-clicked Submit will not create two offers; a dropped-connection retry on a payment intent will not charge twice.

All request and response shapes are exported from @zooly/contracts:

import type {
  ListNotificationsQuery,
  SubmitOfferBody,
  SubmitOfferResponse,
  RespondToOfferBody,
  GetOfferResponse,
  CreatePaymentIntentBody,
  CreatePaymentIntentResponse,
} from "@zooly/contracts";

Testing

cd packages/api-client
pnpm test

9 tests cover: 2xx success envelope, error envelope with code/details, idempotency header attached on writes, GET never attaches it, credentials: include on every request, query-string builder, network-error fallback, missing-JSON-body fallback, baseUrl prefixing.