---
title: "Portal API"
description: "The REST surface under the libraries: portal-token-authenticated, browser-callable, rate-limited by design."
---

The Portal API is what [`@repost/portal-js`](/docs/ui/portal-js) and the [hooks](/docs/ui/portal-react) speak. You rarely call it by hand, since the libraries add token refresh, backoff, and typed responses, but the contract is public and stable.

- **Base URL:** `https://api.repost.sh/portal/v1/`
- **Auth:** `Authorization: Bearer <portal access token>`, the short-lived, per-customer tokens your backend [mints](/docs/send/portal). The token implies the customer and environment; they never appear in URLs.
- **CORS:** open. The token is the credential, so the API is callable directly from any customer dashboard origin.
- **Format:** JSON, ISO-8601 timestamps.

## Errors

Every non-2xx response carries one envelope:

```json
{ "error": { "code": "not_found", "message": "Delivery not found" } }
```

| Code | Status | Meaning |
|------|--------|---------|
| `unauthorized` | 401 | Missing, invalid, expired, or revoked token. Mint a fresh one. |
| `forbidden` | 403 | Read-only session hit a mutation-tier route, or the feature is disabled. |
| `not_found` | 404 | The resource doesn't exist, or isn't owned by the token's customer. |
| `validation_error` | 400 | Bad input; `error.issues` lists `{ path, message }` per field. |
| `conflict` | 409 | State race (e.g. a replay job already active on the endpoint). |
| `rate_limited` | 429 | Budget exhausted. Honor `Retry-After`. |
| `internal` | 500 | Our fault; the message carries a trace id for support. |

## Rate limits

Budgets are cost-weighted (a search costs more than a cheap read, a replay more than a search) and enforced per customer, per environment, and globally. Every response reports the most constrained budget:

| Header | Meaning |
|--------|---------|
| `RateLimit-Limit` | Sustained budget, tokens per minute. |
| `RateLimit-Remaining` | Tokens left right now. |
| `RateLimit-Reset` | Seconds until fully refilled. |
| `Retry-After` | On 429: seconds to wait. |

Pace on `RateLimit-Remaining` before you ever see a 429. For live data, use the [realtime feed](#realtime) instead of polling. The libraries already do both.

## Routes

| Area | Routes |
|------|--------|
| Context | `GET /context`: customer identity, branding, read-only flag. |
| Logs | `GET /logs`, `GET /logs/histogram`, `GET /logs/check-newer`: the delivery-attempt feed and its chart. |
| Deliveries | `GET /deliveries/{id}/attempts` · `POST /deliveries/{id}/replay` (202). |
| DLQ | `GET /dlq`, `GET /dlq/count`, `GET /dlq/count-by-endpoint`. |
| Endpoints | `GET /endpoints` · `POST /endpoints` (201, returns the signing secret once) · `PUT`/`DELETE /endpoints/{id}` · `POST .../pause`, `.../resume` · `POST .../secret/reveal`, `.../secret/rotate` · `GET .../active-replay-job`. |
| Replay jobs | `POST /replay-jobs/estimate` · `POST /replay-jobs` (prepare, 201) · `GET /replay-jobs/{id}`, `GET .../items` · `POST .../confirm`, `.../cancel`, `.../pause`, `.../resume`. |
| Event types | `GET /event-types` · `POST /event-types/{id}/send-sample`: a signed sample to an endpoint or a one-off URL. |
| Realtime | `POST /realtime/token`: mint a websocket tail token. |

List routes paginate with an opaque `cursor` and return `nextCursor` / `hasMore`.

Read-only tokens can call every `GET` plus the realtime mint; everything else answers `403 forbidden`, secret reveal included.

## Realtime

`POST /realtime/token` returns `{ token, realtimeUrl, expiresAtMs }` for the websocket feed of the customer's delivery attempts. The protocol is an implementation detail of the libraries. Subscribe through [`portal.realtime.subscribeLogs`](/docs/ui/portal-js) or [`useLogsFeed`](/docs/ui/portal-react) rather than hand-rolling it.

## The full contract

The OpenAPI document ships with `@repost/portal-js` (its types are generated from it) and in the open-source repo alongside the registry sources.
