@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.
@zooly/api-clientpackages/api-client@zooly/contracts, @zooly/typesimport { 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:
| Option | Default | Purpose |
|---|---|---|
baseUrl | "" (same-origin) | API host. Required when the client is running on a different origin than the API |
idempotencyKey | crypto.randomUUID | Generator for the Idempotency-Key header. Override in tests or SSR environments without crypto.randomUUID |
fetch | globalThis.fetch | Fetch impl. Override for testing |
Every request is sent with credentials: "include" so the auth cookie travels cross-origin.
Result<T> envelopeEvery 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 }.
As of Phase B (W1-01), the client covers the six hot offer-related endpoints:
| Function | Method | Endpoint | Idempotent |
|---|---|---|---|
listNotifications(query?) | GET | /api/notifications/list | — |
listOffers(query?) | GET | /api/offers/list | — |
getOffer(id) | GET | /api/offers/:id | — |
submitOffer(body) | POST | /api/offers/submit | yes |
respondToOffer(body) | POST | /api/offers/respond | yes |
createPaymentIntent(body) | POST | /api/payments/create-intent | yes |
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";
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.
route() builder that consumes the same contracts on the server side.