# Node.js

Call the Pozy API from Node.js and TypeScript with the built-in fetch, including a small copyable wrapper for errors, idempotency, retries, uploads and pagination.

There is no Pozy package to install for Node.js. Node.js 18 and later ship with `fetch`, which is all the API needs. Every Node example in these docs is plain `fetch`.

Create an API key in the dashboard under **Developers → API keys** and put it in the environment:

```bash Terminal
export POZY_API_KEY="pz_live_..."
```

## Publish your first post

This publishes a post to the first connected LinkedIn account right away.

```ts
const headers = {
  Authorization: `Bearer ${process.env.POZY_API_KEY}`,
  "Content-Type": "application/json",
};

const listRes = await fetch("https://api.pozy.ai/v1/accounts?platform=linkedin", { headers });
const listed = await listRes.json();
if (!listRes.ok) throw new Error(listed.error);

const res = await fetch("https://api.pozy.ai/v1/posts", {
  method: "POST",
  headers,
  body: JSON.stringify({
    content: "Hello from the Pozy API",
    platforms: [{ platform: "linkedin", accountId: listed.accounts[0]._id }],
    publishNow: true,
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);

console.log(data.post.status);                          // "published", or "publishing" while it goes out
console.log(data.post.platforms?.[0]?.platformPostUrl); // link to the live post
```

## A small wrapper

Most projects end up with one function that sends the key, encodes JSON and turns error bodies into errors. Copy this one and adjust it as you like:

```ts
export class PozyApiError extends Error {
  constructor(
    public status: number,
    public body: { error?: string; type?: string; code?: string; param?: string; details?: unknown },
    public requestId: string | null,
  ) {
    super(body.error ?? `Pozy API error ${status}`);
  }
}

type Options = { query?: Record<string, string | number | boolean | undefined>; body?: unknown; idempotencyKey?: string };

export async function pozy<T = any>(method: string, path: string, opts: Options = {}): Promise<T> {
  const url = new URL(path, "https://api.pozy.ai");
  for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) url.searchParams.set(k, String(v));
  const headers: Record<string, string> = { Authorization: `Bearer ${process.env.POZY_API_KEY}` };
  if (opts.body !== undefined) headers["Content-Type"] = "application/json";
  if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey;

  const res = await fetch(url, { method, headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) });
  const text = await res.text();
  const data = text ? JSON.parse(text) : null;
  if (!res.ok) throw new PozyApiError(res.status, data ?? {}, res.headers.get("x-request-id"));
  return data as T;
}
```

```ts
import { pozy } from "./pozy";

const { accounts } = await pozy("GET", "/v1/accounts", { query: { profileId: "6650f1c2a8b4e3d2c1b0a998" } });
const scheduled = await pozy("GET", "/v1/posts", { query: { status: "scheduled", limit: 50 } });
await pozy("PUT", "/v1/ads/campaigns/120201234567890/status", { body: { status: "paused", platform: "facebook" } });
```

Paths are exactly as the [API reference](https://pozy.ai/docs/api) prints them, with the path parameters filled in.

## Types

For typed requests and responses, generate types from the [OpenAPI spec](https://pozy.ai/docs/sdks/openapi):

```bash Terminal
npx openapi-typescript https://pozy.ai/openapi.json -o src/pozy.d.ts
```

That page also shows how to use the generated types with a typed fetch client.

## Upload media

Posts take media as URLs. To post a local file, ask Pozy for an upload URL, `PUT` the bytes to it without an `Authorization` header, then use the public URL:

```ts
import { readFile, stat } from "node:fs/promises";
import { pozy } from "./pozy";

const file = "launch.mp4";
const { size } = await stat(file);
const upload = await pozy("POST", "/v1/media/presign", { body: { filename: file, contentType: "video/mp4", size } });

const put = await fetch(upload.uploadUrl, { method: "PUT", headers: { "Content-Type": "video/mp4" }, body: await readFile(file) });
if (!put.ok) throw new Error(`Upload failed with status ${put.status}`);

await pozy("POST", "/v1/posts", {
  body: {
    content: "Launch day.",
    mediaItems: [{ type: "video", url: upload.publicUrl }],
    platforms: [{ platform: "tiktok", accountId: "6650f1c2a8b4e3d2c1b0a111" }],
    publishNow: true,
  },
});
```

Size and format rules per platform are in [Media uploads](https://pozy.ai/docs/publishing/media).

## Idempotency

Send an idempotency key on writes you might retry. A retry with the same key returns the original result instead of doing the work twice.

```ts
await pozy("POST", "/v1/sms/messages", {
  idempotencyKey: `sms-${order.id}`,
  body: { from: "+14155550142", to: "+13125550188", text: "Your order has shipped." },
});
```

Derive the key from your own id for the thing you are sending, and reuse it on every retry. A fresh random key per attempt defeats the purpose. See [Idempotency](https://pozy.ai/docs/getting-started/idempotency).

## Errors

Every error answer has the same JSON body:

| Field | What it holds |
| --- | --- |
| `error` | A message written for people |
| `type` | The error family, such as `invalid_request_error`, `permission_error` or `rate_limit_error` |
| `code` | A stable code, such as `read_only_key` or `profile_out_of_scope` |
| `param` | The field that caused it, when there is one |
| `details` | Extra data, such as per-field validation errors |

The `x-request-id` response header identifies the request for support.

```ts
import { PozyApiError, pozy } from "./pozy";

try {
  await pozy("POST", "/v1/posts", { body: { content: "", platforms: [] } });
} catch (err) {
  if (!(err instanceof PozyApiError)) throw err; // network error, timeout, bug
  if (err.status === 429) {
    console.log("Rate limited. Wait and try again.");
  } else if (err.body.type === "invalid_request_error") {
    console.log(`Fix ${err.body.param ?? "the request"}:`, err.message, err.body.details);
  } else {
    console.log(`${err.status} ${err.body.code}: ${err.message} (request ${err.requestId})`);
  }
}
```

Branch on the status, `type` and `code`, never on the message text. Messages can change; codes such as `read_only_key`, `resource_group_disabled` and `rate_limit_exceeded` do not.

## Retry safely

Retry two kinds of failures: `429`, after the wait the API asks for, and `5xx` or network errors, with backoff. Do not retry other `4xx` errors, because the same request fails the same way.

```ts
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function withRetry(send: () => Promise<Response>, attempts = 4): Promise<Response> {
  for (let i = 1; ; i++) {
    let res: Response | null = null;
    try {
      res = await send();
    } catch (err) {
      if (i >= attempts) throw err; // network error or timeout
    }
    if (res && res.status !== 429 && res.status < 500) return res;
    if (res && i >= attempts) return res;
    const retryAfter = Number(res?.headers.get("retry-after"));
    const wait = retryAfter > 0 ? retryAfter * 1000 : Math.min(30_000, 500 * 2 ** i);
    await sleep(wait + Math.random() * 250);
  }
}

// Same idempotency key on every attempt, so a retry never posts twice.
const res = await withRetry(() =>
  fetch("https://api.pozy.ai/v1/posts", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.POZY_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": "order-1042-thanks",
    },
    body: JSON.stringify(body),
  }),
);
```

## Paginate

List endpoints take `page` and `limit`, and many also take a `cursor`. The response tells you whether there is more:

```ts
import { pozy } from "./pozy";

let page = 1;
for (;;) {
  const data = await pozy("GET", "/v1/posts", { query: { status: "published", page, limit: 100 } });
  for (const post of data.posts ?? []) console.log(post._id, post.content?.slice(0, 40));
  if (!data.pagination || page >= (data.pagination.pages ?? 1)) break;
  page++;
}
```

See [Pagination](https://pozy.ai/docs/getting-started/pagination) for which endpoints use pages and which use cursors.

## Webhooks

To receive events, expose an HTTPS endpoint and check the `X-Pozy-Signature` header, a hex HMAC-SHA256 of the raw request body made with the endpoint secret:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(req: Request) {
  const raw = await req.text(); // the body exactly as received, before JSON.parse
  const expected = createHmac("sha256", process.env.POZY_WEBHOOK_SECRET!).update(raw).digest("hex");
  const received = req.headers.get("x-pozy-signature") ?? "";
  const ok = received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!ok) return new Response("bad signature", { status: 401 });

  const event = JSON.parse(raw);
  console.log(req.headers.get("x-pozy-event"), event);
  return new Response("ok");
}
```

[Verify signatures](https://pozy.ai/docs/webhooks/signatures) explains the check step by step.

## Runtime notes

- Node.js 18 or later, for the built-in `fetch`. Deno, Bun and serverless runtimes have `fetch` too.
- Keep it on the server. A key in browser code is a key anyone can copy.

## Related

- [Authentication](https://pozy.ai/docs/getting-started/authentication): key scopes, read-only keys and resource groups.
- [Errors](https://pozy.ai/docs/getting-started/errors): every `type` and the common `code` values.
- [Rate limits](https://pozy.ai/docs/getting-started/rate-limits): limits per plan and the headers to watch.
- [OpenAPI spec](https://pozy.ai/docs/sdks/openapi): generate types or a client in any language.
