> ## Documentation Index
> Fetch the complete documentation index at: https://plainrouter.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Plainrouter Conversion API: send and verify server events

> Authenticate the Plainrouter Conversion API, submit consented server events, retry safely, inspect Meta delivery, and use the signed OpenAPI contract.

Send server-side conversion events to Plainrouter at `https://plainrouter.com/api/v1/events` using a Signals workspace secret. A new event returns `202`; check its delivery trace separately to confirm Meta acceptance. SDKs and command-line clients use the same Signals REST API.

This is Plainrouter's API, with Plainrouter request fields and authentication. It is not a direct Meta Graph API endpoint. To choose between managed and direct Meta integrations, read the [Meta Conversions API overview](https://plainrouter.com/library/meta-conversions-api).

For one fetchable comparison of REST, SDK, CLI, MCP, and management credentials,
see [Authentication and clients](/docs/api/authentication).

New to the API? Start with the [API introduction](/docs/api/introduction) for the base URL, a synthetic first request, and endpoint navigation. This guide covers production conversion handling and recovery.

## Before you begin

You need an active Signals workspace, its [server-side secret](/docs/api/authentication#how-do-i-authenticate-the-rest-api), and a recorded consent decision for the conversion. To deliver the event to Meta, [connect the intended dataset](/docs/signals/connect-meta) first.

Start with the [identity-free ingestion diagnostic](/docs/signals/verify-ingestion) if you only need to check authentication. Use the [sandbox](/docs/api/sandbox) for synthetic payload validation or a [controlled destination test](/docs/signals/connect-meta#test-without-sending-a-real-purchase) to check Meta delivery. The conversion request below can create a real event and destination delivery.

## Choose an interface

| Interface                           | Use it when                                                                     |
| ----------------------------------- | ------------------------------------------------------------------------------- |
| REST API                            | You want direct control over HTTP requests and responses.                       |
| [TypeScript SDK](/docs/sdk/typescript)   | You want generated types, Zod schemas, and methods in Node.js application code. |
| [Python SDK](/docs/sdk/python)           | You want generated synchronous and asynchronous operations in Python.           |
| [Ruby SDK](/docs/sdk/ruby)               | You want a compact Ruby client over the generated API.                          |
| [Go SDK](/docs/sdk/go)                   | You want a generated client for a Go service.                                   |
| [Plainrouter CLI](/docs/cli/get-started) | You want to test an operation or run an operational script from a terminal.     |
| OpenAPI contract                    | You want to inspect the complete schema or generate another compatible client.  |

The signed REST contract is published at version `0.5.0`. TypeScript SDK and npm CLI `0.5.1`, Go SDK `v0.5.2`, Python package `0.5.2`, and Ruby SDK `0.1.1` target that signed contract. The Python package also includes the PyPI CLI distribution. See [Client releases](#client-releases) for package links and the CLI's command boundary.

## Authenticate

For authenticated Signals operations, send the Signals workspace secret issued during provisioning:

```http theme={null}
Authorization: Bearer YOUR_SIGNALS_WORKSPACE_SECRET
Accept: application/json
Content-Type: application/json
```

The [sandbox operations](/docs/api/sandbox) have separate authentication rules.

Keep this secret on your server or in your CLI's secure credential store. Never put it in browser code, source control, prompts, or logs.

The Conversion API does not accept an MCP OAuth token or a [workspace token](/docs/mcp/workspace-tokens). Workspace execution tokens authorize scoped agent tools. OAuth management credentials are limited to management discovery and cannot authenticate MCP tool calls. Neither authenticates the Signals API.

## Submit an event

Send the request only after a real purchase and a recorded full advertising consent grant. Replace `order-123`, the source URL, and the value with that purchase's data. The consent fields below describe that prerequisite; they do not obtain consent.

Use a stable, non-personal `event_id` for each logical purchase and reuse it across retries. Money values use decimal strings. This immediate-event example omits `event_time`, so Plainrouter uses receipt time. For delayed or queued events, supply the original Unix timestamp or ISO-8601 time from your order record and preserve it on retries. Do not copy a sample timestamp or change an old purchase to the current time.

```bash theme={null}
curl --include https://plainrouter.com/api/v1/events \
  --request POST \
  --header "Authorization: Bearer $PLAINROUTER_SIGNAL_SECRET" \
  --header "Content-Type: application/json" \
  --data '{
    "event_id": "order-123",
    "event_name": "Purchase",
    "event_source": "https://shop.example/thank-you",
    "action_source": "website",
    "consent_basis": "consent",
    "consent": {
      "ad_storage": "granted",
      "ad_user_data": "granted",
      "ad_personalization": "granted",
      "source": "checkout-consent"
    },
    "value_data": {
      "value": "49.90",
      "currency": "EUR",
      "order_id": "order-123"
    }
  }'
```

A new event returns HTTP `202` and a `Location` header for its event resource. A retry with the same event ID returns HTTP `200` with `duplicate: true`; it does not create another conversion. A duplicate response is not a replay of failed destination delivery.

You can supply `Idempotency-Key` instead of a body `event_id`. If you supply both, their values must match. The [submit-event reference](https://plainrouter.com/docs/api/events/create) defines the request and responses.

```json theme={null}
{
  "event_id": "order-123",
  "duplicate": false,
  "warnings": []
}
```

For browser consent and visitor joins, see [Track events and consent](/docs/signals/track-events).

Every `POST /events` request must include `consent_basis`. Use `consent` only with granted downstream advertising state. `legitimate_interest` is restricted to allowlisted lifecycle events and does not create an attributed advertising join.

## Why is the consent capture time invalid?

A new server event authenticated with a Signals workspace secret returns HTTP `202` with a `warnings` array. The array is empty when no warning applies. If you supply `visitor_id` or `user_data` without a parseable consent capture time, the acknowledgement includes `consent_captured_at_invalid`:

```json theme={null}
{
  "event_id": "order-123",
  "duplicate": false,
  "warnings": [
    {
      "code": "consent_captured_at_invalid",
      "field": "consent.captured_at",
      "message": "The event will not join identity data because consent.captured_at is missing or invalid."
    }
  ]
}
```

The event was accepted; the warning does not mean the request failed. A `202` alone does not prove an identity join or Meta delivery. Inspect the acknowledgement before treating the event as fully linked.

1. Read the consent snapshot captured by your application or CMP. Supply its actual decision time in `consent.captured_at`; do not invent consent or replace a missing historical decision with the current time.
2. Use a timestamp with seconds and a timezone, such as `2026-09-14T10:30:00Z` or `2026-09-14T13:30:00+03:00`. An optional fractional second may contain one to six digits. Date-only values, timestamps without a timezone, and impossible calendar dates are invalid.
3. Correct future submissions and inspect the original event's [delivery trace](#how-do-i-confirm-meta-accepted-the-event). Reusing an accepted event ID returns the original duplicate acknowledgement; it does not update that event's consent. Do not create another purchase ID just to remove a warning.

Duplicate HTTP `200` acknowledgements keep their existing response shape and do not include this new array. Browser/publishable-key and sandbox acknowledgements are unchanged. An empty array is not proof of fresh consent: a parseable but stale capture time can still fail the existing identity freshness checks. The warning does not broaden consent, identity, or destination eligibility.

For a server implementation that retains the original consent timestamp, use the [Plainrouter SDK purchase recipe](/docs/sdk/recipes/meta-conversions). The [published OpenAPI contract](https://plainrouter.com/openapi.json) defines the acknowledgement fields; older generated SDK models may not expose newly added fields.

## How do I confirm Meta accepted the event?

Use `GET /events/{event}` for the event ID you submitted. For the example above:

```bash theme={null}
curl https://plainrouter.com/api/v1/events/order-123 \
  --header "Authorization: Bearer $PLAINROUTER_SIGNAL_SECRET" \
  --header "Accept: application/json"
```

The response includes the event and its `deliveries`. For IDs containing characters that need URL encoding, use the returned `Location` URL or the SDK's `getEvent` operation. Keep credentials in the authorization header.

| Result                                   | Interpretation                                   | Next step                                               |
| ---------------------------------------- | ------------------------------------------------ | ------------------------------------------------------- |
| Ingestion `202`                          | Plainrouter accepted a new event for processing. | Inspect its delivery trace.                             |
| Ingestion `200`, `duplicate: true`       | This event ID was already accepted.              | Inspect the original event; retain the ID.              |
| Delivery `queued`, `sent`, or `retrying` | Destination outcome is pending.                  | Check the trace again later.                            |
| Delivery `accepted`                      | Meta accepted this delivery.                     | Verify browser/server pairing and reporting separately. |
| Delivery `failed:*` or `skipped:*`       | The event failed or was ineligible for delivery. | Read the delivery explanation before taking action.     |

Meta acceptance does not establish attribution or improved advertising performance. An empty delivery list does not establish Meta acceptance either. Check the [connected destination](/docs/signals/connect-meta) and the event's eligibility. See [delivery statuses](/docs/reference/statuses-and-terms#destination-delivery) for the complete meanings and [GET event](https://plainrouter.com/docs/api/events/get) for the response schema.

### Read a failed delivery trace

The following is an illustrative excerpt from `GET /events/order-123`; other response fields are omitted. `event.id` identifies the conversion. The numeric delivery `id` identifies one destination attempt record and is the value used for replay.

```json theme={null}
{
  "event": {
    "id": "order-123",
    "event_name": "Purchase",
    "payload_expired": false
  },
  "deliveries": [
    {
      "id": 1042,
      "event_id": "order-123",
      "status": "failed:auth",
      "attempt_count": 1,
      "explanation": "Meta rejected the access token; update credentials before retrying."
    }
  ]
}
```

Here, ingestion succeeded but Meta authentication failed. [Reconnect the intended Meta assets](/docs/signals/connect-meta#refresh-available-assets), then consider replay of this delivery. Repeating `POST /events` with `order-123` only returns the existing event; it does not repair or replay the delivery.

## How do I replay a failed delivery?

Use `POST /deliveries/replay` after correcting the destination problem. Replay sends the existing retained payload again; it does not edit the purchase or create a new event.

Before replay, inspect the event trace and confirm:

* You are using the [Signals workspace secret](/docs/api/authentication#how-do-i-authenticate-the-rest-api) for the event and destination.
* The destination is active, and the event is eligible for attributed advertising delivery.
* The delivery is in a `failed:*` or `expired` state. Pending, accepted, and skipped deliveries are excluded.
* The retained payload is available and the original event time remains within the configured replay window, which cannot exceed seven days. A recent event can still have an unavailable payload.

Replace `1042` with the delivery ID from your trace. This request can contact Meta. Always provide the specific `delivery_ids` you intend to replay: omitting the field or sending an empty array selects a broader set of eligible workspace deliveries.

```bash theme={null}
curl --include https://plainrouter.com/api/v1/deliveries/replay \
  --request POST \
  --header "Authorization: Bearer $PLAINROUTER_SIGNAL_SECRET" \
  --header "Content-Type: application/json" \
  --data '{"delivery_ids":[1042],"limit":1}'
```

HTTP `202` returns counts, for example:

```json theme={null}
{
  "queued": 1,
  "expired": 0,
  "payload_expired": 0,
  "capped": false
}
```

| Field             | Meaning and next step                                                                                                                         |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `queued`          | Deliveries queued again. Read the original event trace until each delivery has an outcome; this count does not prove Meta acceptance.         |
| `expired`         | Candidates that could not be queued because their event was unavailable or outside the replay freshness window. Preserve the real event time. |
| `payload_expired` | Candidates whose retained payload is unavailable. Replay cannot recover the deleted payload.                                                  |
| `capped`          | More candidates matched than the request's processing limit. Inspect the intended IDs before making another bounded request.                  |

These counts are not a per-ID rejection report. All-zero counts can mean the selected ID is outside the workspace or does not meet eligibility requirements. Inspect the trace and [delivery status](/docs/reference/statuses-and-terms#destination-delivery) instead of repeatedly replaying it.

For `failed:permanent`, read the explanation before replaying: sending the same invalid payload again will not correct it. Do not change an old purchase's timestamp or generate a new event ID to bypass duplicate detection or expiry. See the [replay operation reference](https://plainrouter.com/docs/api/deliveries/replay) for optional filters and the complete schema.

## Verify Signal ingestion

The verification operation proves that the authenticated Signal can write to the event ledger without sending identity or destination data:

```bash theme={null}
curl https://plainrouter.com/api/v1/verification-events \
  --request POST \
  --header "Authorization: Bearer $PLAINROUTER_SIGNAL_SECRET" \
  --header "Accept: application/json"
```

A new verification returns `202`; a repeat returns `200` with `duplicate: true`. The request has no body. It confirms ledger receipt but does not count as real arrival evidence or complete onboarding. See [Test Signal ingestion](/docs/signals/verify-ingestion) for the MCP alternative.

## Available operations

SDKs and CLI clients derive their method and command names from these semantic operation IDs:

| Operation ID                                                                         | Method and path                                  | Purpose                                                                        |
| ------------------------------------------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------ |
| [`createEvent`](https://plainrouter.com/docs/api/events/create)                      | `POST /events`                                   | Submit an idempotent conversion event.                                         |
| [`verifySignalIngestion`](https://plainrouter.com/docs/api/events/verify-ingestion)  | `POST /verification-events`                      | Write an identity-free ingestion diagnostic that does not complete onboarding. |
| [`getEvent`](https://plainrouter.com/docs/api/events/get)                            | `GET /events/{event}`                            | Retrieve an event, lineage, and delivery trace.                                |
| [`listEvents`](https://plainrouter.com/docs/api/events/list)                         | `GET /dashboard/events`                          | List recent events and delivery metrics.                                       |
| [`listEventsByCursor`](https://plainrouter.com/docs/api/events/list-by-cursor)       | `GET /dashboard/events/cursor`                   | Page through retained events with an opaque cursor.                            |
| [`setDestinationTestMode`](https://plainrouter.com/docs/api/destinations/test-mode)  | `PATCH /destinations/{destination}/test-mode`    | Enable or disable Meta Test Events mode.                                       |
| [`sendTestPurchase`](https://plainrouter.com/docs/api/destinations/test-purchase)    | `POST /destinations/{destination}/test-purchase` | Send a controlled test purchase.                                               |
| [`replayDeliveries`](https://plainrouter.com/docs/api/deliveries/replay)             | `POST /deliveries/replay`                        | Replay eligible recent deliveries.                                             |
| [`getReconciliationReport`](https://plainrouter.com/docs/api/reports/reconciliation) | `GET /reports/reconciliation`                    | Retrieve reconciliation results for a date.                                    |
| [`getEmqReport`](https://plainrouter.com/docs/api/reports/emq)                       | `GET /reports/emq`                               | Retrieve recent Event Match Quality snapshots.                                 |
| [`deleteUserData`](https://plainrouter.com/docs/api/privacy/delete-user-data)        | `DELETE /user-data`                              | Delete data matching a hashed user identifier.                                 |

Resource IDs remain scoped to the authenticated workspace. A client cannot use its Signals secret to read another workspace's events or destinations.

Cursor listing accepts `per_page` up to 100. Omit `cursor` on the first request, then pass the returned `next_cursor` unchanged to fetch the next page. TypeScript exposes `listEventsByCursor`; Python exposes it through `plainrouter.generated.api.operations.list_events_by_cursor`. The CLI has no cursor-listing command.

## Read workspace identifiers

Signals configuration now belongs directly to the workspace. API responses use the numeric `workspace_id` as the canonical tenant identifier. Existing response shapes also include `signal_tracker_id` as a deprecated decimal-string alias of the same workspace ID.

The currently published `0.5.x` SDKs were generated before `workspace_id` became canonical, so their models may expose only `signal_tracker_id`. You can continue reading that compatibility field until a regenerated client release adds `workspace_id`. Do not treat the two fields as different tenants.

The signed contract marks `user_data_hashed` as a deprecated compatibility field whose value is always `null`. Delivery identity is never returned. Use delivery status and explanation to diagnose the outcome, rather than expecting identity hashes in the trace.

## Delete retained user data

`DELETE /user-data` accepts either a verified raw `identifier` or a legacy SHA-256 `identifier_hash`, together with `identifier_type`. Send the identifier only in the JSON request body. Plainrouter rejects identifiers in the query string so they do not enter URLs, access logs, browser history, caches, or referrer data.

Deletion is idempotent and scoped to the authenticated workspace. The response reports whether the request was a duplicate and how many retained event and session records were updated.

## Use the signed OpenAPI contract

Download or import the canonical contract from:

```text theme={null}
https://plainrouter.com/openapi.json
```

The document uses OpenAPI `3.1.0`, publishes API version `0.5.0`, and includes `x-signed: true`. Plainrouter serves the reviewed bytes with an ETag so automated tooling can detect a new contract without downloading an unchanged document.

Generate clients only from a signed document. Pin the generated SDK or CLI version in production and review contract changes before updating it.

## Client releases

The TypeScript package [`@plainrouter/sdk@0.5.1`](https://www.npmjs.com/package/@plainrouter/sdk), Python package [`plainrouter==0.5.2`](https://pypi.org/project/plainrouter/), Ruby gem [`plainrouter-sdk 0.1.1`](https://rubygems.org/gems/plainrouter-sdk), and Go module [`github.com/plainrouter/sdk-go@v0.5.2`](https://pkg.go.dev/github.com/plainrouter/sdk-go) target the signed Conversion API contract `0.5.0`. All four expose ingestion verification and the required event consent fields. The TypeScript, Python, and Ruby patch releases publish canonical documentation metadata; Go `v0.5.2` improves canonical documentation and package examples without changing the API contract.

Install the CLI from [PyPI](https://pypi.org/project/plainrouter/) with `pipx install plainrouter==0.5.2`, from [npm](https://www.npmjs.com/package/@plainrouter/cli) with `npm install --global @plainrouter/cli@0.5.1`, or from the [official Homebrew tap](https://github.com/plainrouter/homebrew-tap) with `brew install plainrouter/tap/plainrouter`. The PyPI and npm distributions expose the same nine terminal commands and consent-aware event shape. Neither adds a command for `verifySignalIngestion`; use an SDK, direct REST, or MCP for that operation.

## Handle errors

| Status                              | Meaning                                                    | What to check                                                                                                  |
| ----------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `401`                               | The Signals workspace secret is missing or invalid.        | Use the server secret for the intended workspace, not a publishable key or MCP token.                          |
| `404`                               | The event or destination is unavailable to this workspace. | Check its ID, workspace scope, and event retention.                                                            |
| `422`                               | Request validation failed.                                 | Read field errors; check `consent_basis`, consent state, decimal-string values, and matching idempotency keys. |
| `502` on a controlled test purchase | Meta did not accept the test purchase.                     | Inspect the destination test result and Meta connection.                                                       |

Validation responses include a message and field-level errors. Treat `401` as a stopped credential and avoid retrying it indefinitely.

For missing or duplicate conversions after a successful API request, follow [Meta delivery troubleshooting](/docs/signals/connect-meta#troubleshoot).
