---
title: "@repost/portal-js"
description: "The framework-agnostic TypeScript client under the hooks (typed resources over the Portal API, for any frontend)."
---

`@repost/portal-js` is the foundation [`@repost/portal-react`](/docs/ui/portal-react) builds on: a typed client for the [Portal API](/docs/ui/portal-api) that works in any browser app, whether Vue, Svelte, vanilla, or React without the hooks.

```bash
npm install @repost/portal-js
```

```ts
import { createPortalClient } from "@repost/portal-js";

const portal = createPortalClient({
  getToken: () =>
    fetch("/api/repost-portal-token", { method: "POST" })
      .then((res) => res.json())
      .then(({ token }) => token),
});
```

The client caches the token, refreshes it through `getToken` before expiry, and replays a request once after a 401 with a fresh token. (A static `token` option exists for short-lived scripts.)

## Surface

Resource namespaces mirror the [Portal API](/docs/ui/portal-api) one-to-one:

```ts
const context = await portal.context.get();
const { endpoints } = await portal.endpoints.list();
const created = await portal.endpoints.create({ url: "https://example.com/hooks" });

const page = await portal.logs.list({ query: "status:500", limit: 50 });
const histogram = await portal.logs.histogram({ dateFrom, dateTo });
const detail = await portal.deliveries.attempts(page.items[0].deliveryId);

const { count } = await portal.dlq.count();
await portal.deliveries.replay(deliveryId);

const { job } = await portal.replayJobs.prepare({ endpointId, mode: "recover", since, until });
await portal.replayJobs.confirm(job.id);
```

Cursor-paginated lists have `for await` iterators:

```ts
for await (const row of portal.logs.iterate({ endpointId })) {
  // every page, exhaustively
}
```

And the live feed rides a websocket, never polling:

```ts
const subscription = await portal.realtime.subscribeLogs({ endpointId }, {
  onDoc: (doc) => render(doc),
  onStatus: (status) => setLive(status === "live"),
  onDropped: (count) => note(count),
  onInvalidQuery: () => {},
});
// later: subscription.unsubscribe(); portal.realtime.close();
```

## Types

Every request and response type is generated from the Portal API's OpenAPI contract and exported: `Endpoint`, `LogRow`, `Delivery`, `Attempt`, `ReplayJob`, `EventType`, `PortalContext`, ... They cannot drift from the wire: CI regenerates and diffs them against the contract.

## Errors and rate limits

Non-2xx responses throw `PortalApiError` with the HTTP `status`, a machine-readable `code` (`unauthorized`, `not_found`, `validation_error`, `rate_limited`, ...), and `issues` on validation failures.

The client is a good citizen by construction:

- identical concurrent reads coalesce into one request,
- it paces itself from the API's `RateLimit-*` headers before ever hitting the wall,
- 429s retry with jittered backoff honoring `Retry-After`,
- in-flight concurrency is capped.

A runaway render loop in your app exhausts a local cache, not your [rate budget](/docs/ui/portal-api#rate-limits).

## Embedding

The package also ships the iframe embed as a framework-agnostic controller, so
Vue, Svelte and vanilla hosts get exactly what
[`<RepostPortal>`](/docs/send/portal-embed) gives React, including drawers and dialogs that
cover the viewport rather than being clipped to the frame.

```ts
import { createPortalEmbed } from "@repost/portal-js";

const embed = createPortalEmbed({
  url: portalUrl,
  getPortalToken: () => fetch("/api/portal-token").then((r) => r.json()).then((d) => d.token),
  darkMode: prefersDark,
});

embed.mount(document.getElementById("portal")!);
```

`mount(container)` creates the frames and starts the handshake. `update(options)`
applies new options: only a changed `url` or `surface` reloads the frame, so
swapping the token callback or toggling the theme on every render is free.
`unmount()` removes both frames, releases the scroll lock and drops every
listener.

`surface` narrows the frame to one portal area (`"endpoints"`,
`"event-catalog"`, `"logs"` or `"dead-letter-queue"`), with no portal header and
no tabs, for hosts that want their own page structure
([bare surfaces](/docs/send/portal-surfaces)):

```ts
const logs = createPortalEmbed({
  url: portalUrl,
  getPortalToken: getToken,
  surface: "logs",
  onNavigationIntent: ({ surface }) => goTo(`/webhooks/${surface}`),
});
```

The frame stays inside that surface. A navigation that would leave it is
suppressed and reported to `onNavigationIntent` instead, so your app can route
its own page. The customer stays where they are either way.

Options are the same as the React props: `url`, `getPortalToken`, `surface`,
`darkMode`, `overlays`, `overlayZIndex`, `onNavigationIntent`, plus
`onLoadedChange` for driving your own loading state. [Embed the
portal](/docs/send/portal-embed) documents each one, the CSP requirement, and
the fallback behavior on older versions.
