---
title: Plainrouter Conversion API
description: API reference for idempotent conversion ingestion, delivery traces, reporting, replay, and deletion.
canonical: https://plainrouter.com/api/docs
last_updated: 2026-08-26
---

# Plainrouter Conversion API

The Plainrouter Conversion API accepts server-side conversion events, exposes delivery traces and reporting, and provides operational tools for configured Meta destinations.

## Base URL

`https://plainrouter.com/api/v1`

## Zero-auth sandbox

An agent can make a first successful API call without creating an account, generating a key, or touching production data:

```bash
curl https://plainrouter.com/api/v1/sandbox
```

The response contains a ready-to-run request for `POST /api/v1/sandbox/events`. The public sandbox validates only identity-free synthetic event fields in memory, then immediately discards them. It never reads or writes the production ledger and never contacts Meta or another advertising provider.

The discovery response above already embeds a live Sandbox key under `self_serve_key.issued_key`, so a read-only agent reaches working credentials in one request. The same key is also available on its own endpoint, which answers both `GET` and `POST`, with no account or human step:

```bash
curl https://plainrouter.com/api/v1/sandbox/keys
```

The response returns `api_key`, `expires_in`, `scope`, and a ready-to-run request for `POST /api/v1/sandbox/keyed-events`. `GET` returns `200 OK` and `POST` returns `201 Created`; issuance writes nothing, so both are safe to repeat. The key expires after 15 minutes, grants no production access, and is never persisted. The keyless endpoint below remains available for the fastest first call.

```bash
curl -X POST https://plainrouter.com/api/v1/sandbox/events \
  -H 'Content-Type: application/json' \
  -d '{
    "event_id": "sandbox-order-123",
    "event_name": "Purchase",
    "action_source": "website",
    "value_data": {
      "value": "49.90",
      "currency": "EUR",
      "order_id": "sandbox-order-123"
    }
  }'
```

No `Authorization` header is used. Do not submit personal data, customer identifiers, advertising click identifiers, or production event payloads. Identity-bearing fields are rejected. Use the authenticated production endpoint after a human creates a workspace and self-serves its Server secret.

## Authentication

Every production request requires the Server secret as a bearer credential. Sandbox discovery, Sandbox key issuance, and the keyless synthetic event endpoint are the only zero-auth API operations:

```http
Authorization: Bearer YOUR_SERVER_SECRET
Accept: application/json
Content-Type: application/json
```

Keep the Server secret server-side. Do not embed it in browser code or public repositories.

## Rate limits

The API permits 120 requests per 60-second window for each bearer credential. Unauthenticated discovery and authentication failures are limited by client IP address. A narrower endpoint-specific limit may apply to sensitive operations; always follow the headers returned on the current response rather than assuming the general limit.

Every API response carries the two fields defined by [draft-ietf-httpapi-ratelimit-headers-11](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/):

| Header             | Meaning                                                                              |
| ------------------ | ------------------------------------------------------------------------------------ |
| `RateLimit-Policy` | The quota policy in force: `q` is the quota, `w` the window in seconds.              |
| `RateLimit`        | Live state for that policy: `r` is the quota remaining, `t` the seconds until reset. |

```http
RateLimit-Policy: "default";q=120;w=60
RateLimit: "default";r=118
```

Read `r` on every response and slow down as it approaches zero; that is the whole of real-time self-throttling. `w` tells you how long the window is, so you can spread the remaining quota across the time you have.

`t` is only present when Plainrouter knows the exact reset, which is on a throttled response. Its absence means quota remains and no wait is owed — it is not a value to assume. This is deliberate: reporting a full window on every accepted response would park an agent for up to a minute it does not owe.

A `429 Too Many Requests` response adds `Retry-After` in seconds and repeats the reset as `t`:

```http
RateLimit-Policy: "default";q=120;w=60
RateLimit: "default";r=0;t=42
Retry-After: 42
```

Wait at least `Retry-After` seconds before retrying. Where `Retry-After` and `RateLimit` disagree, `Retry-After` wins. Add jitter when several workers share one credential, or they will retry in lockstep and re-exhaust the window together.

Browser-hosted agents can read all of these cross-origin; they are listed in `Access-Control-Expose-Headers`.

`X-RateLimit-Limit` and `X-RateLimit-Remaining` remain available for existing clients. The `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` fields were withdrawn when this API adopted the current draft, which replaces all three with `RateLimit` and gives `RateLimit-Policy` an incompatible syntax. Clients still reading the withdrawn fields should move to `RateLimit`, or to `X-RateLimit-*` if they need a stable non-draft field.

## Endpoints

| Method   | Path                                        | Purpose                                             |
| -------- | ------------------------------------------- | --------------------------------------------------- |
| `GET`    | `/sandbox`                                  | Discover the zero-auth synthetic sandbox.           |
| `GET`    | `/sandbox/keys`                             | Self-serve a short-lived Sandbox key.               |
| `POST`   | `/sandbox/keys`                             | Self-serve a short-lived Sandbox key.               |
| `POST`   | `/sandbox/events`                           | Validate and discard a synthetic event.             |
| `POST`   | `/sandbox/keyed-events`                     | Validate a synthetic event with a Sandbox key.      |
| `POST`   | `/events`                                   | Submit an idempotent conversion event.              |
| `GET`    | `/events/{event}`                           | Retrieve an event, its lineage, and delivery trace. |
| `GET`    | `/dashboard/events`                         | List recent events and delivery metrics.            |
| `GET`    | `/dashboard/events/cursor`                  | Traverse recent events with an opaque cursor.       |
| `PATCH`  | `/destinations/{destination}/test-mode`     | Enable or disable Meta Test Events mode.            |
| `POST`   | `/destinations/{destination}/test-purchase` | Send a controlled test purchase.                    |
| `POST`   | `/deliveries/replay`                        | Replay eligible recent deliveries.                  |
| `GET`    | `/reports/reconciliation?date=YYYY-MM-DD`   | Retrieve reconciliation results for a date.         |
| `GET`    | `/reports/emq`                              | Retrieve recent Event Match Quality snapshots.      |
| `DELETE` | `/user-data`                                | Delete data matching a hashed user identifier.      |

## Submit an event

`event_name` and `consent_basis` are required. Supply a stable `event_id` or `Idempotency-Key` header to make retries idempotent. If you send both, their values must match. Basis `consent` also requires an explicit granted downstream consent snapshot; browser-originated events require granted terminal-equipment storage. Missing basis or contradictory consent is rejected without a ledger event. Money values are decimal strings so their exact representation is preserved.

```http
POST /api/v1/events HTTP/1.1
Host: plainrouter.com
Authorization: Bearer YOUR_SERVER_SECRET
Idempotency-Key: order-123
Content-Type: application/json

{
  "event_id": "order-123",
  "event_name": "Purchase",
  "consent_basis": "consent",
  "consent": {
    "ad_storage": "granted",
    "ad_user_data": "granted",
    "ad_personalization": "granted",
    "captured_at": "2026-08-18T18:00:00Z",
    "source": "customer_consent_system"
  },
  "event_time": 1786272000,
  "event_source": "https://shop.example/thank-you",
  "action_source": "website",
  "user_data": {
    "email": "buyer@example.com"
  },
  "value_data": {
    "value": "49.90",
    "currency": "EUR",
    "order_id": "order-123"
  }
}
```

A newly accepted event returns HTTP `202`. A retry with the same event ID returns HTTP `200` and `duplicate: true`. Both responses include a `Location` header for `GET /api/v1/events/{event}`, where you can poll the event's delivery state. When the request used `Idempotency-Key`, the response echoes that header.

```json
{
    "event_id": "order-123",
    "duplicate": false
}
```

## Errors

- `401` — missing or invalid Server secret.
- `404` — the requested event or destination does not belong to the authenticated customer.
- `422` — request validation failed.
- `429` — rate limit exceeded; follow `Retry-After` before retrying.
- `502` — a controlled Meta test request was not accepted by the upstream API.

The complete machine-readable contract is available from [`/openapi.json`](/openapi.json).

## Pagination

`GET /dashboard/events` preserves page-number pagination for existing clients. For stable agent and automation traversal, start with `GET /dashboard/events/cursor`, then follow `events.next_page_url` until `events.next_cursor` is `null`. Treat cursor values as opaque and never construct them yourself.

## Versioning and deprecation

The current API uses URL versioning under `/api/v1`. Version 1 is active and has no announced sunset date. Read the [versioning and deprecation policy](/api/versioning) for compatibility rules and the response headers Plainrouter publishes before retiring an API version.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
