# TRC20 Payment Gateway — Merchant API Integration Guide

Version: 2026-07-22

Environment: TRON Nile staging

Portal login: `https://portal.auroraex.wickedapp.xyz/login`

Standalone integration demo: `https://auroraex.wickedapp.xyz/demo/`

Rendered API reference: `https://auroraex.wickedapp.xyz/api-reference/`

Styled OpenAPI explorer: `https://auroraex.wickedapp.xyz/openapi/`

Styled Integration Guide: `https://auroraex.wickedapp.xyz/integration-guide/`

Merchant onboarding: `https://auroraex.wickedapp.xyz/onboarding/`

Merchant API base URL: `https://api.auroraex.wickedapp.xyz`

This guide is the merchant-facing integration document for the independent
TRC20 Payment Gateway. It covers request authentication, deposit orders,
withdrawal intents, status polling, and signed webhook callbacks. It does not
cover AEH5 or any AEH5 integration.

For field-by-field requests, responses, status codes, and callback schemas, use
the [conventional REST API reference](merchant-rest-api-reference.md). OpenAPI
is a machine-readable supplement, not a replacement for that reference.

For tenant creation, first-Owner bootstrap, later user invitations, role
assignment, rotation, and offboarding, use the
[Merchant Onboarding Guide](merchant-onboarding-guide.md).

## 1. Credentials and onboarding

There is no shared API secret in this protocol. Authentication uses an
asymmetric Ed25519 keypair:

- the merchant creates and owns the private key (`trc20-api-private.pem`);
- the Gateway stores only the 32-byte raw public key;
- the Gateway generates a public credential identifier such as
  `trc_test_xxxxxxxxxxxxxxxxxxxxxxxx`, returned to the merchant as
  `X-Key-Id`.

### Step 1 — merchant generates the keypair

Run the merchant key generator in the merchant's own secure environment:

```bash
trc20-merchant-keygen \
  --private-key-file ./trc20-api-private.pem
```

If the Gateway Python package is not installed, download and inspect the
dependency-free Node.js 20+ equivalent, then run it locally:

```bash
curl --fail --proto '=https' --tlsv1.2 \
  --output generate-merchant-keypair.mjs \
  https://auroraex.wickedapp.xyz/examples/node/generate-merchant-keypair.mjs
node generate-merchant-keypair.mjs \
  --private-key-file ./trc20-api-private.pem
```

The command refuses to overwrite an existing file and writes the private
PKCS#8 PEM with mode `0600`. It prints public handoff metadata only:

```json
{
  "algorithm": "Ed25519",
  "private_key_file": "trc20-api-private.pem",
  "public_key_base64": "<BASE64_RAW_32_BYTE_PUBLIC_KEY>",
  "public_key_fingerprint_sha256": "<SHA256_FINGERPRINT>"
}
```

Store the private key in the merchant's secret manager. Send only the
`public_key_base64` and fingerprint to the Gateway operator. Never send the
private key through the Portal, email, support tickets, or chat.

During onboarding, also provide the Gateway operator with:

- the HTTPS webhook URL;
- the source IP ranges that will call the Merchant API;
- the required scopes: `orders:read`, `orders:write`, `withdrawals:read`, and
  `withdrawals:write`.

### Step 2 — Gateway operator registers or rotates the credential

For a new Merchant, `trc20-admin create-tenant` registers this public key and
prints the initial `trc_test_...` credential ID. That command is documented in
the [Merchant onboarding guide](merchant-onboarding-guide.md); do not also run
`issue-credential` for the same initial key.

Use `issue-credential` only for an existing tenant, an additional independently
rotatable application credential, or key rotation. After independently
confirming the new public-key fingerprint, the operator runs:

```bash
trc20-admin issue-credential \
  --tenant-key-id <TENANT_KEY_ID> \
  --environment test \
  --scope orders:read \
  --scope orders:write \
  --scope withdrawals:read \
  --scope withdrawals:write \
  --public-key <PUBLIC_KEY_BASE64>
```

Both `create-tenant` and `issue-credential` use a cryptographically secure
random generator to create the credential ID and print it once. The operator
sends that non-secret ID back to the merchant as `X-Key-Id`.

The operator returns:

- `X-Key-Id`, which identifies the registered public key;
- the Gateway webhook Ed25519 public key, which the merchant pins separately;
- confirmation of the enabled webhook events.

The Nile default API budget is 300 requests per minute per credential with a
burst of 30. The onboarding handoff contains the actual environment-specific
budget; it is not currently returned by `/v1/capabilities`.

The Portal session and Merchant API credential are separate authentication
systems. To rotate a credential, issue and verify a new public-key credential
first, then revoke the old `X-Key-Id`; the Portal's current revoke action does
not create a replacement keypair.

The Merchant API Ed25519 private key is an application identity key, not a
TRON wallet key. A separate mTLS client private key authenticates the Merchant
workload at the API edge. Neither Merchant key can control Gateway-held funds.
The Gateway generates and maintains all deposit-address and withdrawal-wallet
private keys. The Merchant provides only its collection destination address;
the Gateway never needs that address's private key.

## 2. Request authentication

Every `/v1` request must include:

| Header | Requirement |
| --- | --- |
| `X-Key-Id` | Key ID returned during onboarding |
| `X-Timestamp` | Current Unix timestamp in seconds |
| `X-Nonce` | Unique 16–128 character ASCII value; never reuse it |
| `Idempotency-Key` | Required for `POST`; stable for retries of the same logical request |
| `X-Signature` | Base64 Ed25519 signature of the canonical request |

Build the canonical UTF-8 bytes in this exact order:

```text
UPPERCASE_METHOD + "\n" +
PATH_WITH_QUERY + "\n" +
X_TIMESTAMP + "\n" +
X_NONCE + "\n" +
IDEMPOTENCY_KEY_OR_EMPTY + "\n" +
LOWERCASE_HEX_SHA256_OF_EXACT_BODY_BYTES
```

For example, a `GET /v1/capabilities` request uses an empty request body, an
empty idempotency-key line, and the SHA-256 digest of zero bytes. Sign the path
starting at `/v1`, not the hostname. The bytes hashed and signed must be the
same bytes sent over HTTP; do not serialize the JSON body a second time after
signing it.

### Node.js 20+ signing example

```js
import { createHash, randomBytes, sign } from "node:crypto";
import { readFileSync } from "node:fs";
import { request as httpsRequest } from "node:https";

const baseUrl = process.env.TRC20_API_BASE_URL;
const keyId = process.env.TRC20_API_KEY_ID;
const privateKey = readFileSync(process.env.TRC20_API_PRIVATE_KEY_FILE);
const mtlsCertificate = readFileSync(process.env.TRC20_MTLS_CERT_FILE);
const mtlsPrivateKey = readFileSync(process.env.TRC20_MTLS_KEY_FILE);
const privateCa = process.env.TRC20_MTLS_CA_FILE
  ? readFileSync(process.env.TRC20_MTLS_CA_FILE)
  : undefined;

function referencePathSegment(reference) {
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(reference)) {
    throw new Error("Merchant reference has an invalid format");
  }
  // Do not percent-encode allowed characters. The literal path is signed and
  // sent, so a reference such as ORDER:123 remains ORDER:123 in both places.
  return reference;
}

async function gatewayRequest(method, path, payload, idempotencyKey = "") {
  const body = payload === undefined ? "" : JSON.stringify(payload);
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = randomBytes(16).toString("hex");
  const bodyHash = createHash("sha256").update(body).digest("hex");
  const canonical = [method.toUpperCase(), path, timestamp, nonce, idempotencyKey, bodyHash]
    .join("\n");
  const signature = sign(null, Buffer.from(canonical, "utf8"), privateKey).toString("base64");

  const headers = {
    Accept: "application/json",
    "X-Key-Id": keyId,
    "X-Timestamp": timestamp,
    "X-Nonce": nonce,
    "X-Signature": signature,
  };
  if (body) headers["Content-Type"] = "application/json";
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;

  return await new Promise((resolve, reject) => {
    const request = httpsRequest(new URL(path, baseUrl), {
      method,
      headers,
      cert: mtlsCertificate,
      key: mtlsPrivateKey,
      ca: privateCa,
      rejectUnauthorized: true,
    }, (response) => {
      const chunks = [];
      response.on("data", (chunk) => chunks.push(chunk));
      response.on("end", () => {
        const result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
        if ((response.statusCode ?? 500) >= 300) {
          reject(new Error(`${response.statusCode}: ${JSON.stringify(result)}`));
          return;
        }
        resolve(result);
      });
    });
    request.on("error", reject);
    if (body) request.write(body);
    request.end();
  });
}
```

The certificate and Ed25519 private key are different files. The API hostname
requires mTLS before the HTTP request reaches the application, so a plain
`fetch()` example without client-certificate configuration cannot call
Staging.

## 3. API endpoints

All amount fields are decimal strings with at most six decimals. Never parse
money with binary floating point.

### 3.1 Verify authentication and capabilities

`GET /v1/capabilities`

Required scope: none beyond an active credential.

```js
const capabilities = await gatewayRequest("GET", "/v1/capabilities");
```

The response identifies the TRON network, asset, token decimals, order TTL,
and approval behavior. `arbitrary_signing_supported` is always `false`.

### 3.2 Create a deposit order

`POST /v1/orders`

Required scope: `orders:write`

```js
const orderRef = "ORDER-20260719-0001";
const order = await gatewayRequest(
  "POST",
  "/v1/orders",
  {
    order_ref: orderRef,
    expected_amount: "25.500000",
    metadata: { member_id: 12345 },
  },
  "idem-order-20260719-0001",
);
```

A new order returns HTTP `201`. An exact replay with the same `order_ref`,
body, and idempotency key returns HTTP `200` with
`idempotent_replay: true`. Reusing the reference with different input returns
HTTP `409`.

The response contains the one-time `deposit_address`. Present that address and
the exact USDT-TRC20 amount to the customer.

### 3.3 Query a deposit order

`GET /v1/orders/{order_ref}`

Required scope: `orders:read`

```js
const order = await gatewayRequest("GET", `/v1/orders/${referencePathSegment(orderRef)}`);
```

Order status is one of:

- `pending`
- `partially_paid`
- `paid`
- `underpaid`
- `overpaid`
- `expired`
- `manual_review`

Use this endpoint as the recovery source of truth if a webhook is delayed or
missed.

### 3.4 Create a withdrawal intent

`POST /v1/withdrawals`

Required scope: `withdrawals:write`

```js
const withdrawalRef = "WD-20260719-0001";
const withdrawal = await gatewayRequest(
  "POST",
  "/v1/withdrawals",
  {
    withdrawal_ref: withdrawalRef,
    merchant_user_ref: "member-12345",
    destination_address: "<USER_CONTROLLED_TRON_ADDRESS>",
    amount: "10.000000",
    metadata: { source: "aurora-h5" },
  },
  "idem-withdrawal-20260719-0001",
);
```

The endpoint records an intent only. It never signs or broadcasts a transfer
synchronously. A new intent returns HTTP `202`; an exact replay returns HTTP
`200`. Policy and approval rules determine the returned status.

`merchant_user_ref` is a required, stable, opaque identifier from the
Merchant's own user/account system. It is shown to Merchant signers in the
Portal so they can identify who requested the withdrawal. Do not send a name,
email address, phone number, or other direct personal data in this field.

### 3.5 Query a withdrawal

`GET /v1/withdrawals/{withdrawal_ref}`

Required scope: `withdrawals:read`

```js
const withdrawal = await gatewayRequest(
  "GET",
  `/v1/withdrawals/${referencePathSegment(withdrawalRef)}`,
);
```

The lifecycle can expose these states:

- routing: `scheduled`, `awaiting_approval`, `manual_review`, `approved`,
  `rejected`;
- signing: `reserved`, `signing`, `prepared`;
- delivery: `broadcast`, `retry`, `confirmed`, `failed`.

Only `confirmed` means the withdrawal has reached a successful solidified
on-chain receipt. When available, `tx_id` contains the TRON transaction ID.
`received` and `validating` are short-lived but observable API states.
`processing` is not a withdrawal status. See the REST reference for the
canonical status table and the separate Portal-only
`awaiting_merchant_authorization` projection.

## 4. Webhook callbacks

The Gateway sends callbacks to the pre-registered HTTPS endpoint. It never
follows redirects. Persist the event before returning any `2xx`; all other
responses are retried with backoff.

The callback URL must use HTTPS port 443, contain no username/password, and
resolve only to globally routable addresses. Delivery uses a five-second
connect timeout and fifteen-second read timeout. Backoff is capped at one hour
and an event dead-letters after 12 failed attempts.

The callback URL is registered once during Merchant onboarding; it is not sent
inside an order or withdrawal request. For an `abc.com` integration, provide
the Gateway operator with the exact HTTPS receiver URL (for example,
`https://abc.com/payments/trc20/webhook`) and the complete event list. The
operator activates a versioned endpoint with `set-webhook-endpoint`. API
callers cannot change the destination per request, preventing a compromised
request credential from redirecting payment events.

The state transition and outbox insert commit atomically. A dedicated callback
worker checks for pending deliveries continuously and, when idle, polls every
two seconds. Therefore an approval or chain transition normally becomes a
callback shortly after commit, but the Merchant must not depend on a fixed
delivery delay: HTTP/network failures use exponential retry and may end in a
dead-letter after 12 failed attempts. Reconcile with
`GET /v1/orders/{order_ref}` or `GET /v1/withdrawals/{withdrawal_ref}` whenever
a callback is late or ambiguous.

Events can be delivered concurrently and arrive out of order. For withdrawal
status events, apply only a strictly newer `transition_version` for the same
`withdrawal_ref`. If both `withdrawal.status_changed` at `confirmed` and the
legacy `withdrawal.confirmed` event are enabled, treat them as the same business
completion and deduplicate the completion by `withdrawal_ref` plus consistent
`tx_id`, not only by event ID.

Headers:

| Header | Meaning |
| --- | --- |
| `X-Webhook-Id` | Stable event ID; use it as the deduplication key |
| `X-Webhook-Timestamp` | Unix timestamp in seconds |
| `X-Webhook-Signature` | Base64 Ed25519 signature from the Gateway |

There is currently no webhook key-version header. For a signing-key rotation,
temporarily accept both independently pinned Gateway public keys while the
Gateway operator coordinates the cutover, then remove the retired key only
after the overlap evidence is complete.

Webhook canonical UTF-8 bytes:

```text
X_WEBHOOK_TIMESTAMP + "\n" +
X_WEBHOOK_ID + "\n" +
LOWERCASE_HEX_SHA256_OF_EXACT_BODY_BYTES
```

### Node.js 20+ verification example

```js
import { createHash, createPublicKey, verify } from "node:crypto";

function strictBase64(value, expectedBytes) {
  if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
    throw new Error("malformed Base64");
  }
  const decoded = Buffer.from(value, "base64");
  if (decoded.length !== expectedBytes || decoded.toString("base64") !== value) {
    throw new Error("unexpected Base64 length or encoding");
  }
  return decoded;
}

function webhookPublicKey(rawPublicKeyBase64) {
  const raw = strictBase64(rawPublicKeyBase64, 32);
  const ed25519SpkiPrefix = Buffer.from("302a300506032b6570032100", "hex");
  return createPublicKey({
    key: Buffer.concat([ed25519SpkiPrefix, raw]),
    format: "der",
    type: "spki",
  });
}

function verifyGatewayWebhook({ rawBody, headers, rawPublicKeyBase64, now = Date.now() }) {
  const eventId = headers["x-webhook-id"];
  const timestamp = headers["x-webhook-timestamp"];
  const signatureBase64 = headers["x-webhook-signature"];
  if (!eventId || !/^[0-9]+$/.test(timestamp ?? "") || !signatureBase64) return false;

  const ageMs = Math.abs(now - Number(timestamp) * 1000);
  if (!Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000) return false;

  const bodyHash = createHash("sha256").update(rawBody).digest("hex");
  const canonical = `${timestamp}\n${eventId}\n${bodyHash}`;
  const valid = verify(
    null,
    Buffer.from(canonical, "utf8"),
    webhookPublicKey(rawPublicKeyBase64),
    strictBase64(signatureBase64, 64),
  );
  if (!valid) return false;

  const event = JSON.parse(rawBody.toString("utf8"));
  return event.event_id === eventId ? event : false;
}
```

Verify the signature against the raw HTTP body before parsing JSON. Reject stale
timestamps and malformed Base64. Store `X-Webhook-Id` under a unique database
constraint so repeated delivery cannot double-credit or double-update a record.

### PHP 8.2 receiver example

The delivery package contains a complete raw-body verifier and a minimal HTTP
entrypoint:

- [`examples/php/src/WebhookVerifier.php`](../examples/php/src/WebhookVerifier.php)
  performs timestamp, strict-Base64, Ed25519, event-ID, and event-catalog checks;
- [`examples/php/public/webhook.php`](../examples/php/public/webhook.php) reads
  `php://input` exactly once and returns `204` only after verification; and
- [`examples/php/bin/verify-webhook.php`](../examples/php/bin/verify-webhook.php)
  is a command-line verifier for saved callback fixtures.

The HTTP shape is:

```php
<?php

declare(strict_types=1);

use Trc20Gateway\Example\WebhookVerifier;

require_once __DIR__ . '/../bootstrap.php';

$rawBody = file_get_contents('php://input');
$verifier = new WebhookVerifier((string) getenv('TRC20_WEBHOOK_PUBLIC_KEY_BASE64'));
$event = $verifier->verify($rawBody, [
    'X-Webhook-Id' => $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '',
    'X-Webhook-Timestamp' => $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
    'X-Webhook-Signature' => $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
]);

$handlerFile = getenv('TRC20_WEBHOOK_HANDLER_FILE');
if ($handlerFile === false || !is_file($handlerFile)) {
    throw new RuntimeException('Durable webhook handler is not configured.');
}
$handler = require $handlerFile;
if (!is_callable($handler) || $handler($event) !== true) {
    throw new RuntimeException('Webhook was not durably committed.');
}
http_response_code(204); // only after the Merchant transaction commits
```

The transaction body is intentionally Merchant-specific, but signature
verification is executable as delivered. Run `php examples/php/tests/run.php`
before adapting the handler. Do not acknowledge first and process later unless
the Merchant has its own durable inbox with equivalent atomic deduplication.

The Gateway currently emits exactly these subscription events:
`deposit.confirmed`, `order.expired`, `sweep.confirmed`,
`sweep.manual_review`, `withdrawal.status_changed`, and
`withdrawal.confirmed`. Endpoint subscriptions are explicit; an event not in
the active endpoint version is not delivered.

For existing tenants, adding `withdrawal.status_changed` requires the Gateway
operator to create a new endpoint version. The command is a complete
replacement, so it must include every event the Merchant still needs:

```bash
trc20-admin set-webhook-endpoint \
  --tenant-key-id <TENANT_KEY_ID> \
  --url https://<MERCHANT_HOST>/webhook \
  --event deposit.confirmed \
  --event order.expired \
  --event sweep.confirmed \
  --event sweep.manual_review \
  --event withdrawal.status_changed \
  --event withdrawal.confirmed
```

### 4.1 Deposit/order callbacks

`deposit.confirmed` example:

```json
{
  "event": "deposit.confirmed",
  "event_id": "deposit:abc123:0",
  "order_ref": "ORDER-20260719-0001",
  "currency": "USDT_TRC20",
  "amount": "25.500000",
  "received_total": "25.500000",
  "status": "paid",
  "tx_id": "abc123",
  "block_number": 59876543
}
```

`order.expired` is delivered when an unpaid order passes its TTL. Always confirm
the latest state with `GET /v1/orders/{order_ref}` before applying recovery or
support actions.

### 4.2 Withdrawal callback

Subscribe the webhook endpoint to `withdrawal.status_changed` and
`withdrawal.confirmed` during onboarding. The occurrence-versioned status event
reports every public transition; the terminal confirmation event remains for
backward compatibility. If the same endpoint also receives deposit or sweep
events, include those values again when the endpoint version is replaced.

```json
{
  "event": "withdrawal.confirmed",
  "event_id": "withdrawal:55:confirmation",
  "withdrawal_ref": "WD-20260719-0001",
  "currency": "USDT_TRC20",
  "amount_atomic": 10000000,
  "tx_id": "def456",
  "destination_address": "<USER_CONTROLLED_TRON_ADDRESS>"
}
```

`amount_atomic` uses six token decimals, so `10000000` represents
`10.000000 USDT`. Confirm the final state using
`GET /v1/withdrawals/{withdrawal_ref}`.

An example `withdrawal.status_changed` occurrence is:

```json
{
  "event": "withdrawal.status_changed",
  "event_id": "withdrawal:17:91:v4:withdrawal.status_changed",
  "withdrawal_ref": "WD-20260719-0001",
  "status": "broadcast",
  "previous_status": "prepared",
  "transition_version": 4,
  "currency": "USDT_TRC20",
  "amount": "10.000000",
  "amount_atomic": 10000000,
  "destination_address": "<USER_CONTROLLED_TRON_ADDRESS>",
  "tx_id": "def456",
  "occurred_at": "2026-07-20T08:07:12.123456Z"
}
```

For a newly created withdrawal, the initial transition out of `validating`
uses `previous_status: null` and `transition_version: 1`. Migrated or already
in-flight withdrawals can begin their public event stream at a later status
and version, so consumers must not assume that every first-seen event has
`previous_status: null` or version `1`. Deduplicate by `event_id`, not by status
name: a withdrawal can legitimately enter `retry` more than once.

Merchant final-sign acceptance resumes the withdrawal; it does not mean funds
are already on-chain. The Merchant receives subsequent
`withdrawal.status_changed` callbacks as the Gateway progresses. Treat
`withdrawal.confirmed`, or a `withdrawal.status_changed` occurrence whose
status is `confirmed`, as the successful terminal result. Rejection and
failure transitions are also reported through `withdrawal.status_changed`.

## 5. Retry, idempotency, and error handling

- Reuse the same `Idempotency-Key` only when retrying the same logical `POST`
  with identical bytes.
- Generate a new nonce and timestamp for every HTTP attempt, including an
  idempotent retry.
- On HTTP `401`, check the canonical field order, clock, nonce uniqueness,
  Key ID, exact body bytes, and Base64 signature.
- HTTP `403` means the key does not hold the required scope.
- HTTP `409` means a replay, immutable reference conflict, or exhausted
  address pool; inspect the JSON `error` and `request_id`.
- HTTP `429` includes `Retry-After`; wait before retrying.
- For `5xx` or network timeouts, retry with backoff. For `POST`, reuse the same
  logical reference and idempotency key while generating fresh authentication
  headers.
- Log the response `X-Request-Id` and JSON `request_id` for support cases.

## 6. PHP 8.2 and Postman delivery package

The executable PHP example is in [`examples/php`](../examples/php). It reads
the PKCS#8 Ed25519 key produced by `trc20-merchant-keygen`, signs the exact JSON
bytes, and configures cURL with the mTLS certificate and key. It includes:

- create and query deposit order commands;
- create and query withdrawal commands;
- raw-body webhook verification;
- deterministic cross-language signature vectors; and
- rejection checks for tampering, stale timestamps, wrong events, wrong keys,
  and malformed Base64.

Run its offline checks with:

```bash
php examples/php/tests/run.php
```

Import [`docs/postman/TRC20-Payment-Gateway.postman_collection.json`](postman/TRC20-Payment-Gateway.postman_collection.json)
and [`docs/postman/TRC20-Nile-Staging.postman_environment.json`](postman/TRC20-Nile-Staging.postman_environment.json)
for Nile exploration. Configure mTLS in Postman host certificates and keep the
Ed25519 PEM only in the environment's local secret current value. Never sync or
export a filled environment.

## 7. Go-live checklist

- Private key exists only in the merchant server secret store.
- Staging and production use different keypairs and Key IDs.
- Application clock is synchronized.
- Raw request and webhook bytes are retained until signature verification.
- `order_ref`, `withdrawal_ref`, idempotency keys, nonces, and webhook IDs have
  database uniqueness constraints appropriate to their purpose.
- Webhook handler verifies signature and timestamp before any business write.
- Deposit crediting and withdrawal completion are driven by verified callbacks
  plus status polling reconciliation.
- Nile end-to-end tests pass before mainnet credentials or broadcast are
  enabled.

The machine-readable contract is `docs/openapi.yaml` in the delivery package.
