AI agents should fetch https://remote.ivjn.us/SKILL.md.

# HTTP API for Agent operations

> HTML: https://remote.ivjn.us/docs/en/http-api/

Native sealed operations now support HTTP-only callers. Use `remote-cli --transport http` or `REMOTE_TRANSPORT=http`. `POST /api/sealed/connections` creates a mailbox; `POST /input` submits signed handshakes or ciphertext, `GET /events` polls results, and `DELETE` closes the connection. Clients retain the existing end-to-end cryptography and device authorization. The original device-action endpoints still return 409. The device connection remains unchanged. HTTP mailboxes require affinity to the creating Exchange instance and expire after 90 seconds without activity. Serialize inputs, retry only the same sequence and ciphertext, and acknowledge processed events with `after`. Web Console, mobile and LAN retain their existing transport. Preset execution still requires `manage`.

https://remote.ivjn.us/sealed-http.md


HTTP `409` / `Sealed sessions are required.` on a device-action endpoint means a sealed session is required; a Bearer token alone cannot execute device actions. Use `remote-cli --profile alice exec lab -- uname -a`, or `remote-cli --profile alice --transport http exec lab -- uname -a` in HTTP-only environments. The CLI still performs the signed handshake and end-to-end encryption.


## Base URL

Production Exchange:

```text
https://exchange.ivjn.us
```

Self-hosted deployments use your own HTTPS origin. All paths below are relative to that base. Send JSON with `Content-Type: application/json`.

## Authentication

Every `/api/*` route (except public auth/register helpers) requires:

```http
Authorization: Bearer <token>
```

`<token>` may be either:

1. **Password-login session token** from `POST /api/auth/login`
2. **Operator primary API key** (the long-lived token shown once when the operator account was created)

Both are accepted by the same `authenticate_operator` path used by the console and CLI.

### Password login

```bash
curl -sS -X POST 'https://exchange.ivjn.us/api/auth/login' \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@example.com","password":"your-password-here"}'
```

`email` is the account identity and must be a valid address. The service normalizes it (trim, lowercase) and requires the address to be verified. Existing deployments can still send `{ "username": "alice", "password": "..." }` until those accounts move to email; a value containing `@` in `username` is treated as an email. An invalid address is rejected before the password check.

Example response:

```json
{
  "username": "alice",
  "email": "alice@example.com",
  "tenant_id": "00000000-0000-0000-0000-000000000000",
  "token": "<prefix>.<secret>",
  "expires_in": 2592000
}
```

Use `token` as the Bearer credential. Call `POST /api/auth/logout` with the same header to revoke that session. Primary operator API keys are not revoked by logout.

### Operator API key (primary token)

```bash
TOKEN='<prefix>.<secret>'   # issued at operator creation; store offline
curl -sS 'https://exchange.ivjn.us/api/devices' \
  -H "Authorization: Bearer ${TOKEN}"
```

## List devices

```bash
curl -sS 'https://exchange.ivjn.us/api/devices' \
  -H "Authorization: Bearer ${TOKEN}"
```

Each item includes `id`, `name`, `online`, `enabled`, `info`, timestamps, and `active_sessions`. Device inventory is Exchange metadata. Listing a device does not establish the sealed session needed to operate it.

## Device-action HTTP rejection and session administration

All device operations use sealed WebSocket sessions. The HTTP routes below cannot establish the client/device handshake and therefore return HTTP `409` for authenticated, authorized requests that pass request validation. Rejection occurs before device dispatch: no command, sensor request, network probe, update, ubus call, held-job request, or pinned command is sent to the agent. An online device does not make these routes executable.

Authentication, permissions, and input validation still apply. Invalid credentials return `401`; forbidden operations return `403`; invalid commands, URLs, or request bodies retain their validation errors, including `400`. The sealed-session `409` does not replace earlier validation or authorization failures. Permission checks for pinned commands follow the pinned-command policy; passing them still does not permit HTTP execution.

| Method and path | Behavior after authentication, authorization, and validation |
| --- | --- |
| `POST /api/devices/{id}/exec` | `409`: execute inside a sealed WebSocket session |
| `GET /api/devices/{id}/exec/holds` | `409`: inspect held jobs inside a sealed WebSocket session |
| `POST /api/devices/{id}/exec/holds/{job_id}/kill` | `409`: stop held jobs inside a sealed WebSocket session |
| `GET /api/devices/{id}/exec/holds/{job_id}/cat` | `409`: read held-job output inside a sealed WebSocket session |
| `POST /api/devices/{id}/sensors` | `409`: collect sensors inside a sealed WebSocket session |
| `POST /api/devices/{id}/net` | `409`: run network probes inside a sealed WebSocket session |
| `POST /api/devices/{id}/update` | `409`: check or apply updates inside a sealed WebSocket session |
| `POST /api/devices/{id}/ubus` | `409`: call ubus inside a sealed WebSocket session |
| `POST /api/devices/{id}/commands/{cmd}/run` | `409`: fetch the pinned command and execute it inside a sealed WebSocket session with a device-authorized manage grant |

For example, this valid action request is a rejection check, not a command-execution interface:

```bash
curl -i -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/exec" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"command":"uname -a","timeout_secs":30}'
```

Expect HTTP `409` with the English error message `Sealed sessions are required.` (localized according to the request language). Establish a native sealed WebSocket session to perform the operation. There is no successful command-output response from this HTTP route.

### Exchange session administration

Session inventory and cancellation remain available over HTTP because they administer Exchange routing metadata. They do not execute device operations or open an additional device session.

| Method and path | Success body |
| --- | --- |
| `GET /api/devices/{id}/sessions` | `{sessions:[DeviceSessionInfo], limit, count}` |
| `POST /api/devices/{id}/sessions/{session_id}/kill` | `{session_id, released}` |

`DeviceSessionInfo` fields are `session_id` (device-side numeric ID), `operator_session_id`, `kind`, `hold`, `age_secs`, and `deadline_unix_ms`. `limit` is `max_sessions_per_device` (default 32); `count` is the current list length. Listing reads local Exchange hub state directly; a locally offline device returns `503`. Kill uses the **device-side** ID, sends the appropriate cancellation (including for held exec), and releases the route locally even if the device does not answer. A missing session or locally offline device returns HTTP `200` with `released:false`. Readonly operators receive `403` for both endpoints.

```bash
curl -sS "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/sessions" \
  -H "Authorization: Bearer ${TOKEN}"

curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/sessions/${SESSION_ID}/kill" \
  -H "Authorization: Bearer ${TOKEN}"
```

Before rejecting a full session cap, Exchange reclaims dead-operator routes and past-deadline routes: exec timeout + 15 seconds, file transfer 3600 seconds + 15 seconds, other kinds 24 hours. Held exec within its deadline detaches on operator disconnect rather than being killed.

### Native sealed CLI operations

The CLI establishes sealed sessions as part of normal device operations. Complete workspace-root approval and device authorization first; see [sealed sessions](/docs/en/sealed-sessions.md). There is no plaintext opt-out. Normal commands need no extra sealing flag:

```bash
remote-cli exec "$DEVICE_ID" -- uname -a
remote-cli shell "$DEVICE_ID"
remote-cli exec-holds "$DEVICE_ID"
remote-cli exec-hold-cat "$DEVICE_ID" "$JOB_ID"
remote-cli exec-hold-kill "$DEVICE_ID" "$JOB_ID"
remote-cli sensors "$DEVICE_ID" --json
remote-cli net "$DEVICE_ID" ping 1.1.1.1
remote-cli net "$DEVICE_ID" http https://example.com/
remote-cli ubus "$DEVICE_ID" system board '{}'
remote-cli commands list "$DEVICE_ID"
remote-cli commands run "$DEVICE_ID" "$COMMAND_ID"
```

Held-job operations run inside sealed sessions; Exchange session administration is separately available through `remote-cli sessions` and `remote-cli session-kill`. Fetching pinned-command metadata does not authorize execution: the device must accept the sealed operator's manage grant.

## Sealed-session key directory and grant relay

Exchange stores operator public keys and owner-signed `rsg1.` grant tokens so clients can discover each other. **This is not authorization.** Devices still verify the owner signature against local policy. Exchange only indexes the token payload (`owner_key`, `operator_key`, `device_id`, `level`, `expires_at`) and never treats upload as permission. Exchange cannot forge root signatures, but can withhold credentials, misrepresent the roster to an auto-approving root client, observe metadata or deny service.

The workspace root (`rsr1.`) and grant invitations (`rsi1.`) are the same kind of relay. Exchange checks the token shape and the Ed25519 self-signature (an invitation must also match the stored root key) and does **not** decide which operator keys are approved. `GET /api/seal/roster` is a superadmin view of members, roles, and `operator_device_grants`. Clients must not treat a newly appeared key as trusted until the root client approves it locally. See `docs/sealed-sessions.md`.

Any authenticated workspace member may list keys, register their own key, and upload grants (grants are self-authenticating). `DELETE` of a key is limited to the registering account (admins may delete any). `DELETE` of a grant is limited to the uploader (admins may delete any). `GET /api/seal/grants` skips expired rows.

Key registration proof is Ed25519 over:

```text
remote-seal-key-register:v1:<workspace_id>:<operator_id>:<public_key>:<issued_at_ms>
```

`issued_at_ms` must be within ±10 minutes of the server clock. `public_key` and `proof` are standard base64 (32-byte key, 64-byte signature).

| Method and path | Body | Success |
| --- | --- | --- |
| `POST /api/seal/keys` | `{"public_key","label"?,"proof","issued_at_ms"}` | registered key (upsert for the same account) |
| `GET /api/seal/keys` | — | `[{operator_id, username, email, public_key, label, created_at, last_seen_at}]` |
| `DELETE /api/seal/keys/{key}` | — | `204` (`{key}` is URL-encoded public key) |
| `POST /api/seal/grants` | `{"tokens":["rsg1.…"]}` | `{stored:[…], errors:[…]}` |
| `GET /api/seal/grants?mine=1` | — | grants whose `operator_key` is one of the caller's registered keys. Each grant whose `owner_key` is not the published root also includes `chain` (certificate tokens, possibly empty) |
| `GET /api/seal/grants?owner_key=…` | — | grants signed by that owner key |
| `DELETE /api/seal/grants/{id}` | — | `204` |
| `PUT /api/seal/root` | `{"token":"rsr1.…","rotation":"rsx1.…"?}` | stored workspace root. Superadmin only. Shape and the key's own signature are checked; this is not an approval. `rotation` (optional) must be signed by the currently published root and name the new root; Exchange then pushes `seal_root {token, rotation}` to online sealed devices, which re-pin only when the rotation is signed by the root they already trust. Without a valid rotation, devices that pinned the old root keep it |
| `GET /api/seal/root` | — | `{workspace_id, root_key, payload, signature, token, created_at}`, or `404` when none is published |
| `POST /api/seal/invitations` | `{"token":"rsi1.…"}` | stored invitation. Superadmin only, and only after a root exists |
| `GET /api/seal/invitations` | — | unexpired invitations, newest first (`[]` when none) |
| `GET /api/seal/roster` | — | `{members:[{operator_id, username, email, role, restricted, enabled, keys:[{public_key, label}], device_grants:[{device_id, can_manage}]}]}`. Superadmin only. Includes disabled members |

Directory unavailability does not bypass the sealed-session or device-grant requirement. `GET /api/seal/root` returns `404` when no workspace root has been published.

```bash
# Register this operator key (proof signed locally)
curl -sS -X POST "$BASE/api/seal/keys" \
  -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
  -d '{"public_key":"<ed25519-b64>","label":"cli","proof":"<sig-b64>","issued_at_ms":1700000000000}'

curl -sS "$BASE/api/seal/keys" -H "Authorization: Bearer ${TOKEN}"

curl -sS -X POST "$BASE/api/seal/grants" \
  -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
  -d '{"tokens":["rsg1.…"]}'

curl -sS "$BASE/api/seal/grants?mine=1" -H "Authorization: Bearer ${TOKEN}"
```

### Intermediate certificates (`rsc1.`)

Exchange stores intermediate certificates so a non-root key can sign grants that devices will accept. It **verifies** the issuer's Ed25519 signature and **cannot forge** a certificate. Uploading a token is not authorization: the caller must be the issuer's operator. A root-signed certificate (`issuer_key` equals the published workspace root) may be published only by the workspace superadmin. An intermediate may be published only by the account whose registered seal key is `issuer_key`, and that issuer must itself be the subject of a stored, unrevoked, unexpired certificate with `can_issue`. The child must stay inside the parent's level, device scope, and expiry (the same checks devices apply). Re-publishing the same `(issuer_key, subject_key)` replaces the older row and clears revocation.

`GET /api/seal/certs/chain?subject_key=` returns `{chain:[tokens]}` walking issuer links up to the published root, at most 4 certificates, root-issued first, or `404`. Any authenticated member may call it. `GET /api/seal/grants?mine=1` adds that `chain` on grants whose signer is not the root, so clients receive it with the grant. Grants signed by the root omit `chain`.

`GET /api/seal/certs` lists every row, including revoked ones (administrators and the superadmin). `DELETE /api/seal/certs/{serial}` (the issuer's account, or the superadmin) sets `revoked`. That only stops distribution. Devices keep honoring a copy they already have until it expires; hard revocation is the device policy's `revoked_keys`. `{serial}` is the URL-encoded standard-base64 serial.

| Method and path | Body | Success |
| --- | --- | --- |
| `POST /api/seal/certs` | `{"token":"rsc1.…"}` | stored certificate (upsert by issuer and subject) |
| `GET /api/seal/certs/chain?subject_key=` | — | `{chain:["rsc1.…"]}`, root-issued first, or `404` |
| `GET /api/seal/certs` | — | `[{serial, issuer_key, subject_key, token, expires_at_ms, created_at, revoked}]`. Admin or superadmin |
| `DELETE /api/seal/certs/{serial}` | — | `204`. Issuer or superadmin. Stops distribution only |

```bash
curl -sS -X POST "$BASE/api/seal/certs" \
  -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
  -d '{"token":"rsc1.…"}'

curl -sS "$BASE/api/seal/certs/chain?subject_key=<ed25519-b64>" \
  -H "Authorization: Bearer ${TOKEN}"
```

### Workspace root key backup (optional)

`PUT` / `GET` / `DELETE /api/seal/backup` store one opaque `rsb1.` blob per account. `PUT` and `GET` are superadmin-only: the blob is the workspace **root** private key, encrypted on the client with Argon2id (64 MiB, t=3, p=1) and ChaCha20-Poly1305. The passphrase is optional and has no enforced strength. **With an empty or weak passphrase, whoever holds the blob (including Exchange) can decrypt the root key and control every device.** Uploading is optional; clients can export the root key (`rsk1.`) and keep it offline instead. Exchange checks that `blob` starts with `rsb1.` and its length, and never decrypts. `GET` is limited to 5 requests per account per hour per process (HTTP 429). Reads, writes and deletes are audited with the byte count only. Responses send `Cache-Control: no-store`.

| Method and path | Body | Success |
| --- | --- | --- |
| `PUT /api/seal/backup` | `{"blob":"rsb1.…"}` | `{blob, updated_at}` |
| `GET /api/seal/backup` | — | `{blob, updated_at}`, or `404` when none is stored. Rate limited |
| `DELETE /api/seal/backup` | — | `204`, including when no row exists |

```bash
curl -sS -X PUT "$BASE/api/seal/backup" \
  -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
  -d '{"blob":"rsb1.…"}'

curl -sS "$BASE/api/seal/backup" -H "Authorization: Bearer ${TOKEN}"

curl -sS -X DELETE "$BASE/api/seal/backup" -H "Authorization: Bearer ${TOKEN}"
```

## Browser extension root-chain authentication

Browser operations use native sealed WebSocket sessions, not HTTP one-shot
execution. The extension self-generates an Ed25519 operator key. With an operator
Bearer token it obtains `tenant_id` / `operator_id` from `/api/auth/me`, then calls
`POST /api/seal/keys` with `{public_key, label, proof, issued_at_ms}`. PoP signs:

```text
remote-seal-key-register:v1:<workspace_id>:<operator_id>:<public_key>:<issued_at_ms>
```

The key/signature are standard base64 (32/64 bytes); time must be within ±10
minutes of the server clock. Registration proves possession, not authorization.
The extension syncs `GET /api/seal/root`, `GET /api/seal/grants?mine=1` and
certificate tokens included with grants or fetched from
`GET /api/seal/certs/chain?subject_key=<url-encoded-key>`. Grants must verify to
the trusted workspace root, directly or through a valid certificate chain, with
the correct device scope, level and validity. Device-token registration remains
separate from this operator-token directory flow.

The extension verifies incoming native hellos through its root and includes its
own signed hello/grant/chain in `SealAccept.peer`. CLI/console verify that proof
and require `peer.operator_key == accept.device_key` before accepting the signed
response. Rust agents use their normal root policy when the extension acts as
an operator. Invalid/missing proofs fail closed; there is no separate browser
trust store/UI or legacy fallback. Native-device and workspace-root TOFU remain;
the extension stores its first root per server/workspace and rejects changes.

**Security:** verify the first root out of band; PoP or a server role is not
root approval. Review unknown keys before root-client auto-approval. Protect
endpoint profiles/keys and hosted scripts. Directory deletion does not revoke
cached signed credentials. Exchange can see metadata, withhold credentials or
deny service; sealing does not isolate a hostile full-CDP operator.

## Choosing WebSocket or HTTPS

| Need | Use |
| --- | --- |
| Commands, held-job inspection or cancellation, sensors, network probes, updates, ubus, pinned commands | Native sealed WebSocket session |
| Interactive PTY, streaming output, file transfer, Android screen/tree/tap | Native sealed WebSocket session |
| Authentication, device inventory, key/grant/certificate directory, root backup | HTTPS API |
| Exchange routing-session inventory and cancellation | HTTPS session-administration endpoints |
| Live device presence and long-lived operator connection | WebSocket; device operations on the connection still require sealing |

Browser ticket flow (credentials never go in the WebSocket URL):

1. `POST /api/ws-ticket` with `Authorization: Bearer …`.
2. Connect to `GET /ws/browser` and send first JSON frame `{"ticket":"…"}`.
3. Establish the native sealed client/device handshake before sending any device operation. The operator's grant and certificate chain must validate against the root trusted by the device.

Native clients use `GET /ws/operator` with operator authentication and the same sealed-session requirement. A WebSocket connection or successful account login alone does not authorize a device operation. See [Web console and Web Push](/docs/en/web-console-and-notifications.md), [protocol](/docs/en/websocket-protocol.md), and [sealed sessions](/docs/en/sealed-sessions.md).

## Error status cheat sheet

| Status | Typical cause |
| --- | --- |
| `400` | Invalid action input, such as an empty command or invalid update source URL; validation occurs before the sealed-session rejection |
| `401` | Missing or invalid Bearer token |
| `403` | Account or device permissions do not authorize the request |
| `409` | Valid, authorized HTTP device action requires a sealed WebSocket session; no device dispatch occurs |
| `404` | Missing directory entry or unpublished workspace root, according to the endpoint contract |
| `429` | Endpoint-specific rate limit, including workspace-root backup reads |
| `503` | Session inventory cannot find the device in local Exchange hub state |

Device-action HTTP routes do not wait for agent output, so their normal authorized result is `409`, not an execution success, offline-dispatch error, or HTTP execution timeout. Errors from metadata, authentication, and directory APIs retain their endpoint-specific meanings.

## Manual validation plan

Use a test account and device with known permissions. HTTP rejection checks do not require executing work on a device:

1. Authenticate and list devices over HTTPS; verify the account and device permissions.
2. Submit a valid request to each device-action route in the rejection table. Expect HTTP `409` and confirm no action reaches the agent, including held-job reads and pinned-command runs.
3. Check invalid credentials, forbidden accounts, and invalid request input separately. Expect the authentication, permission, and validation errors rather than assuming every request returns `409`.
4. Read Exchange session inventory. For an explicitly selected disposable session, test cancellation and verify `released`; a missing session returns `released:false`. Session listing on a locally offline device returns `503`.
5. Complete root approval and grants, then run a harmless native CLI operation such as `remote-cli exec "$DEVICE_ID" -- uname -a`. Verify it succeeds through the sealed handshake. A missing or invalid device grant must fail closed.
6. Validate key registration, grant/certificate retrieval, optional root backup, and browser root-chain authentication independently; those HTTPS APIs remain available under the contracts above.

## Audit export destinations

Supported by PostgreSQL Exchange only, scoped to the authenticated workspace. Administrators and superadmins only; other accounts receive 403. Not supported by exchange-worker; no Web/mobile Console configuration UI.

```text
GET    /api/audit/sinks
POST   /api/audit/sinks
PATCH  /api/audit/sinks/{id}
DELETE /api/audit/sinks/{id}
POST   /api/audit/sinks/{id}/test
GET    /api/audit/sinks/status
```

```sh
printf '%s' '{"name":"archive","kind":"webhook","endpoint":"https://audit.example.net/events","secret":"REPLACE-WITH-RANDOM-SECRET","mode":"full"}' | remote-cli audit sink add
remote-cli audit sink list
printf '%s' '{"interval_ms":500,"batch_size":16}' | remote-cli audit sink update 1
remote-cli audit sink test 1
remote-cli audit sink status
remote-cli audit sink remove 1
```

Default throttled exports stored audit records: text over 256 KiB retains the first 250 KiB and last 6 KiB, files are hashes, and streams export after session completion. full captures before truncation, splitting original JSON and binary content into chunks (default 1 MiB), with operation/asset IDs, sequence, offset, final marker, SHA-256 and total size. Both modes export only metadata and ciphertext references/hashes for sealed sessions; neither recovers endpoint plaintext.

Secrets and headers are encrypted and read back as ***. PATCH preserves ***; a null header removes it. Delivery uses asynchronous durable FIFO queues and exponential retries. Throttled queues drop and count oldest records at capacity; full disk queues apply backpressure. Status includes success/failure, backlog, dropped and gaps. Removal discards pending data and audits the count. Configuration changes are audited in the same transaction.

Webhooks send a JSON events array with base64 bytes. Verify X-Remote-Timestamp and X-Remote-Signature: sha256=<hex> using HMAC-SHA256(timestamp + "." + raw body). OTel uses OTLP/HTTP JSON /v1/logs, body.bytesValue for chunks, remote.device.id / remote.operator / remote.action attributes, and configurable service.name. gRPC requires a Collector bridge; there is no direct gRPC exporter.

Default configuration, webhook payload, signature verification and Collector examples follow. Verify the raw HTTP body; persist and deduplicate by delivery_id before returning 2xx. Enable allow_structured_metadata in Loki 3.x, add a Loki data source in Grafana and query {service_name="remote-exchange"}. For gRPC, change pipeline exporters to [otlp/grpc]. Use TLS and authentication in production.

```json
{"name":"collector","kind":"otel","mode":"throttled","endpoint":"https://collector.example.net:4318/v1/logs","headers":{"Authorization":"Bearer REPLACE"},"service_name":"remote-exchange","resource":{"deployment.environment.name":"production"},"batch_size":32,"interval_ms":1000,"chunk_bytes":1048576,"queue_limit":10000,"overflow_bytes":10737418240,"actions":[],"devices":[],"operators":[],"enabled":true,"ca_pem":null}
```

```json
{"version":1,"events":[{"delivery_id":123,"type":"full","mode":"full","entry":{"id":42,"action":"exec","operator_name":"alice","device_id":"dev"},"data":{"kind":"binary","asset_id":"asset-uuid","path":"/data_base64","sequence":0,"offset":0,"last":true,"data_base64":"YWJj","total_size":3,"sha256":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad","context":{"direction":"output","frame_sequence":0,"stream_offset":0}}}]}
```

```python
import hashlib, hmac, time

def verify(headers, raw_body, secret):
    stamp = headers['X-Remote-Timestamp']
    if abs(time.time() - int(stamp)) > 300:
        raise ValueError('expired webhook')
    expected = 'sha256=' + hmac.new(
        secret.encode(), stamp.encode() + b'.' + raw_body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, headers['X-Remote-Signature']):
        raise ValueError('invalid signature')
```

```yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 127.0.0.1:4318
processors:
  batch: {}
exporters:
  otlphttp/loki:
    endpoint: http://loki:3100/otlp
  otlp/grpc:
    endpoint: telemetry.example.net:4317
    headers:
      Authorization: "${env:OTEL_AUTHORIZATION}"
    tls:
      insecure: false
service:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/loki]
```

| Audit field | OTLP field |
|---|---|
| service_name / resource | Resource service.name / attributes |
| entry.started_at | timeUnixNano |
| entry.id / delivery_id | remote.audit.id / remote.delivery.id |
| entry.device_id / operator_name / action | remote.device.id / remote.operator / remote.action |
| entry.tenant_id | remote.workspace.id |
| entry.started_at / ended_at / detail | remote.started_at / remote.ended_at / remote.result |
| traceId / spanId | traceId / spanId |
| data.data_base64 | body.bytesValue / remote.chunk.* |
| stored events | body.stringValue |
