zapi.
APISPRICINGDOCSMCP
GitHub

Build with Zapi,
fully typed.

zpi-sdk is the official TypeScript SDK for the Zapi scraper catalog — zero dependencies, typed end to end, with built-in retries, streaming, and bulk jobs. Everything below also documents the raw REST API it wraps.

OverviewInstallationQuickstartAuthenticationConfigurationrun()StreamingBulkCatalogError handlingRetries & timeoutsTyped resultsCodegen CLIWebhooksMCP clientREST APINext steps

▸ Getting started

  • Overview
  • Installation
  • Quickstart
  • Authentication

▸ Core

  • Configuration
  • run()
  • Streaming
  • Bulk
  • Catalog

▸ Reliability

  • Error handling
  • Retries & timeouts

▸ TypeScript

  • Typed results
  • Codegen CLI

▸ More

  • Webhooks
  • MCP client
  • REST API
  • Next steps

Overview

zpi-sdk is a universal, zero-runtime-dependency TypeScript SDK for Zapi. It ships a dual ESM + CJS build (sideEffects: false) and runs on Node ≥ 20, Bun, Deno, and in the browser behind a proxy. MIT-licensed, source at github.com/zeative/zpi-sdk.

Entry pointWhat it is
zpi-sdkCore client: run(), stream(), bulk, catalog, errors.
zpi-sdk/mcpIsolated MCP client for the remote Zapi MCP server.
zpi-sdk/webhooksVerify + parse signed incoming webhook deliveries.
zpi-sdk/codegenNode-only type generator for the scraper catalog.
zpi (bin)CLI — currently the codegen command.

Installation

terminal
npm i zpi-sdk
terminal
pnpm add zpi-sdk
terminal
yarn add zpi-sdk
terminal
bun add zpi-sdk
deno — import from the npm registry
import { ZpiClient } from "npm:zpi-sdk";

Quickstart

Create a client with your API key and call run() with a project key in category:scraper format:

quickstart.ts
import { ZpiClient } from "zpi-sdk";

const client = new ZpiClient({ apiKey: "zpi_..." });

// projectKey is "category:scraper" — no HTTP method needed,
// the SDK detects and remembers the right verb per endpoint
const profile = await client.run("social:instagram", "profile", {
  username: "instagram",
});
console.log(profile);

The response envelope is { project, data, timestamp } — the SDK unwraps and returns data directly.

Authentication

apiKey is the only required option — the constructor throws if it's missing. It's sent as the x-api-key header on every request. Create a key at Dashboard → API Keys and copy it once — the full key can't be recovered later.

auth.ts
import { ZpiClient } from "zpi-sdk";

const client = new ZpiClient({ apiKey: process.env.ZPI_API_KEY });

// The key lives in a private field and never leaks through serialization:
JSON.stringify(client); // "{}"

▸ Key redaction is built in: config lives in a private field and client.toJSON() returns {}, so the key never leaks through JSON.stringify, error dumps, or logs.

Configuration

Everything new ZpiClient(options) accepts:

OptionTypeDefaultPurpose
apiKeystring— (required)Your Zapi key; sent as x-api-key. Constructor throws if missing.
baseURLstringhttps://api.zpi.web.idAPI origin override (staging, self-hosted).
defaultHeadersRecord<string, string>{}Extra headers merged into every request.
fetchtypeof fetchglobal fetchInjectable fetch implementation (polyfill, instrumentation).
timeoutMsnumber30000Per-request timeout.
maxRetriesnumber2Max retry attempts for retryable failures.
baseRetryDelayMsnumber200Exponential-backoff base delay.

run()

run<T = unknown>(projectKey, endpoint, params?, opts?): Promise<T> issues a single scraper call. The request URL is {baseURL}/v1/{projectKey}/{endpoint}[/{pathRest}]. You don't pick an HTTP method — the SDK auto-detects the endpoint's verb (a wrong first guess is flipped once on 405 and the learned verb is memoized per endpoint). GET params become the query string, POST params the JSON body. Path params like :slug are plain fields in params — no URL templating needed.

run.ts
// No method to pick — auto-detected (and memoized) per endpoint.
const spot = await client.run("finance:goldprice", "spot", {});

// Endpoints with path params (e.g. article/:slug)? Path params are
// just regular fields in params — all of these are equivalent:
await client.run("finance:goldprice", "article", { slug: "gold-hits-ath" });
await client.run("finance:goldprice", "article/:slug", { slug: "gold-hits-ath" });

// Explicit method still works and skips auto-detection:
const data = await client.run(
  "social:instagram",
  "profile",
  { username: "instagram" },
  { method: "GET" }
);
RunOptsTypePurpose
method"GET" | "POST"Optional override; omitted = auto-detected and memoized per endpoint.
signalAbortSignalExternal abort signal, composed with the timeout.
timeoutMsnumberPer-call override of the client timeout.
idempotencyKeystringRequired to make a POST retry-eligible; reused across attempts.
headersRecord<string, string>Per-request header overrides.
pathReststringAppended to the URL after the endpoint segment.

Streaming

stream(projectKey, endpoint, params?, opts?) returns an AsyncIterable<StreamEvent> for SSE and chunked endpoints. text/event-stream responses yield SseEvent objects ({ event?, data, id? }); any other body yields raw text chunks.

stream.ts
for await (const event of client.stream("ai:chat", "completions", {
  prompt: "hi",
})) {
  // SSE responses yield { event?, data, id? }; other bodies yield raw text
  console.log(typeof event === "string" ? event : event.data);
}

▸ Streams are never retried — a broken stream throws instead of silently replaying. StreamOpts is RunOpts minus idempotencyKey.

Bulk

client.bulk.submit(projectKey, endpoint, items) POSTs to /v1/{projectKey}/{endpoint}/bulk and returns a BulkJob handle. The SDK attaches an automatic Idempotency-Key (crypto.randomUUID) so retries never double-submit. client.bulk.status(jobId) does a single poll.

bulk.ts
const job = await client.bulk.submit("social:instagram", "profile", [
  { url: "https://instagram.com/a" },
  { url: "https://instagram.com/b" },
]);

const result = await job.wait({
  onProgress: (j) => console.log(j.succeeded, "/", j.total),
});
for (const item of result.items ?? []) {
  console.log(item.status, item.data ?? item.error);
}

job.wait({ signal?, timeoutMs?, pollIntervalMs?, onProgress? }) polls with backoff (base 500 ms, capped at 5 s) until the job is terminal. Job statuses: QUEUED | RUNNING | COMPLETED | FAILED | CANCELLED; per-item statuses: QUEUED | RUNNING | SUCCEEDED | FAILED.

▸ COMPLETED, FAILED, and CANCELLED all resolve — per-item failures live in items[]. Only transport errors, timeouts, and aborts throw. A client-side timeout leaves the job running server-side; poll again with bulk.status(jobId).

Catalog

client.catalog reads the public scraper catalog — no auth required, served outside /v1:

MethodREST endpointReturns
list({ q?, cat?, cursor?, limit? })GET /api/scrapers/listCursor-paginated { items, nextCursor, total }.
get(slug)GET /api/scrapers/{slug}Full scraper detail.
categories()GET /api/public/categoriesAll catalog categories.
schema(slug, endpoint)GET /api/public/scrapers/{slug}/endpoints/{endpoint}/schemaEndpoint field schema { fields }.
stats(slug)GET /api/public/scrapers/{slug}/stats{ requests, successRate }.
catalog.ts
const { items } = await client.catalog.list({ cat: "social", limit: 20 });
const detail = await client.catalog.get("social:instagram");
const schema = await client.catalog.schema("social:instagram", "profile");

▸ get / schema / stats accept both the "category:scraper" project key and the bare "scraper" slug.

Error handling

Every failure throws ZpiError or a subclass. The base class carries status, code?, raw, and requestId? (from the x-request-id header) — include the request id when reporting issues.

ClassTriggerStatusExtra fields
ZpiInvalidParamsErrorParameter validation failed400 / 422errors[{ path?, message? }]
ZpiExecErrorScraper ran but failed400error, errors, context?, project?
ZpiBulkCapErrorBulk item cap exceeded400cap?, submitted?
ZpiAuthErrorBad or missing API key401—
ZpiPlanGateErrorPlan tier too low403requiredPlan?, upgradeUrl?
ZpiBulkNotEnabledErrorBulk disabled for endpoint403—
ZpiNotFoundErrorScraper/endpoint not found404—
ZpiMethodNotAllowedErrorWrong HTTP method405—
ZpiIdempotencyErrorIdempotency key conflict422—
ZpiRateLimitErrorRate limit exceeded429limit?, used?, window?, retryAfterSec?, retryAfter?, requested?
ZpiServerErrorBackend error500—
ZpiDisabledErrorEndpoint disabled503—
ZpiNetworkError / ZpiTimeoutError / ZpiAbortErrorTransport failure / timeout / abort0cause
ZpiMcpErrorMCP JSON-RPC error0code, data
errors.ts
import {
  ZpiError,
  ZpiPlanGateError,
  ZpiRateLimitError,
} from "zpi-sdk";

try {
  const data = await client.run("social:instagram", "profile", {
    username: "instagram",
  });
} catch (e) {
  if (e instanceof ZpiPlanGateError) {
    console.log("upgrade:", e.requiredPlan, e.upgradeUrl);
  } else if (e instanceof ZpiRateLimitError) {
    console.log("retry after", e.retryAfterSec, "s");
  } else if (e instanceof ZpiError) {
    console.log(e.status, e.code, e.raw);
  }
}

Retries & timeouts

The client retries only network errors and HTTP 429 / 502 / 503 / 504, with exponential backoff (base × 2ⁿ plus jitter). Other 4xx responses are never retried.

  • The Retry-After header and the 429 body's retryAfterSec are honored — the server-provided delay wins over the backoff schedule.
  • POST requests are retried only when you pass an idempotencyKey; the same key is reused across attempts so nothing runs twice.
  • Streams are never retried.
  • Timeouts use an AbortController composed with your external signal — a timeout throws ZpiTimeoutError, your own abort throws ZpiAbortError.

Typed results

run() is generic, and the ScraperMap interface supports declaration merging — register a scraper's shape once and every call to it is fully typed. Params are enforced: a missing required field is a compile error, not a runtime 400.

zpi-sdk.gen.d.ts
// The export {} makes this file a module AUGMENTATION —
// without it the block would shadow the real package types.
export {};

declare module "zpi-sdk" {
  interface ScraperMap {
    "social:instagram": {
      profile: {
        params: { username: string };
        result: { id: string; name: string };
      };
    };
  }
}

You rarely write this by hand — the codegen CLI below emits it from the live catalog.

Codegen CLI

npx zpi codegen crawls the catalog (concurrency 5) and emits a types-only .d.ts with zero runtime imports. Endpoints with unusable schemas fall back to Record<string, unknown> — never any.

terminal
npx zpi codegen --base https://api.zpi.web.id --out ./zpi-sdk.gen.d.ts

# narrow to one category, authenticate for gated schemas
npx zpi codegen --filter social --key zpi_xxx
FlagPurpose
--baseCatalog origin (or env ZPI_BASE_URL — CLI only).
--outOutput path; defaults to ./zpi-sdk.gen.d.ts.
--filterLimit generation to matching scrapers (e.g. social).
--keyAPI key, for schemas that require auth.

Programmatic use: generate({ baseURL, out, fetch?, filter?, key? }) from zpi-sdk/codegen resolves to { written, scrapers, endpoints }.

Webhooks

Webhooks push events to your server — bulk.completed / bulk.failed / bulk.item, quota.warning / quota.exceeded, request.error, key.created / key.disabled / key.deleted, and webhook.test. Manage endpoints at Dashboard → Webhooks. Every delivery is signed: X-Zpi-Signature: sha256=<hex(hmac_sha256(secret, body))>, with the event name in X-Zpi-Event.

zpi-sdk/webhooks verifies and parses deliveries in one call — Web Crypto with a timing-safe compare, so the same code runs on Node, Bun, Deno, and edge runtimes:

webhook.ts
import { parseWebhook } from "zpi-sdk/webhooks";

// In your HTTP handler — pass the RAW body string, not parsed JSON:
const event = await parseWebhook(rawBody, {
  signature: req.headers["x-zpi-signature"],
  secret: process.env.ZPI_WEBHOOK_SECRET,
});
// → { id, event: "bulk.completed" | ..., data, deliveredAt }
// Throws ZpiWebhookVerifyError on a bad signature or malformed payload.

switch (event.event) {
  case "bulk.completed":
    console.log("job done:", event.data);
    break;
  case "quota.warning":
    console.log("almost out of quota");
    break;
}

▸ Always verify against the raw request body — a re-serialized JSON.parse round-trip can reorder bytes and break the signature. Failed deliveries are retried with exponential backoff; permanent 4xx responses stop retries.

MCP client

zpi-sdk/mcp is a zero-dependency JSON-RPC client for the remote Zapi MCP server (Streamable HTTP, protocol 2025-06-18, session via mcp-session-id). RPC failures throw ZpiMcpError.

mcp.ts
import { createMcpClient } from "zpi-sdk/mcp";

const mcp = createMcpClient({
  apiKey: "zpi_...",
  baseURL: "https://api.zpi.web.id",
});
const tools = await mcp.listTools();
const result = await mcp.callTool("run_scraper", { /* ... */ });

Connecting an AI client (Claude, Cursor, VS Code, …) instead? See the MCP server docs — tools reference, per-client setup, and limits.

REST API

The SDK wraps a plain REST API — usable from any language. Base URL https://api.zpi.web.id, scraper calls at /v1/{category:scraper}/{endpoint}, authenticated with the x-api-key header:

curl
curl -X POST "https://api.zpi.web.id/v1/social:instagram/profile" \
  -H "x-api-key: zpi_xxx" \
  -H "content-type: application/json" \
  -d '{"username":"instagram"}'

# response envelope — the SDK unwraps .data for you
# { "project": "social:instagram", "data": { ... }, "timestamp": ... }

Error statuses match the error table above — 401 bad key, 403 plan gate, 429 rate limit (respect Retry-After), 5xx retry with backoff. Bulk jobs live at POST /v1/{category:scraper}/{endpoint}/bulk and GET /v1/bulk/{jobId}.

Next steps

catalog is publicruns bill per plan
Browse endpoints →Get API key →MCP server →
Major outage
zapi. — Public REST API catalog. No fabricated numbers.
TermsPrivacyAUP© 2026