# TRC20 Payment Gateway Merchant REST API

- Version: 2026-07-22
- Staging network: TRON Nile
- Media type: `application/json`
- API base URL: `https://api.auroraex.wickedapp.xyz`

This is the conventional human-readable REST reference. The machine-readable
contract is [`openapi.yaml`](openapi.yaml), the importable Postman collection is
[`postman/TRC20-Payment-Gateway.postman_collection.json`](postman/TRC20-Payment-Gateway.postman_collection.json),
its Nile environment is
[`postman/TRC20-Nile-Staging.postman_environment.json`](postman/TRC20-Nile-Staging.postman_environment.json),
and executable PHP 8.2 code is in [`../examples/php`](../examples/php).

Tenant, API-credential, first-Owner, invitation, role, rotation, and offboarding
procedures are in the [Merchant Onboarding Guide](merchant-onboarding-guide.md).

The Merchant Portal login and Merchant REST API use different authentication.
Portal cookies cannot call `/v1`, and API credentials cannot log into the
Portal.

## Security model

Every `/v1` Merchant business call uses both controls. The `/health/*`
operational probes are mTLS-only and do not consume Merchant credentials:

1. **mTLS** — present the client certificate and key issued for the dedicated
   Merchant API hostname.
2. **Ed25519 request signing** — present `X-Key-Id` and sign the canonical
   request with the Merchant API private key.

These are Merchant identity keys, not blockchain keys. The Gateway generates
and controls all private keys for deposit addresses and its withdrawal wallet.
The Merchant supplies only its collection destination address; the Gateway
does not need that address's private key.

### Credential scope matrix

| Operation | Required scope |
| --- | --- |
| `GET /v1/capabilities` | Active environment-matched credential |
| `POST /v1/orders` | `orders:write` |
| `GET /v1/orders/{order_ref}` | `orders:read` |
| `POST /v1/withdrawals` | `withdrawals:write` |
| `GET /v1/withdrawals/{withdrawal_ref}` | `withdrawals:read` |

Staging accepts only a `test` credential; Mainnet accepts only a `live`
credential. A valid signature made with a credential for the wrong environment
is rejected as an unknown credential.

### Required request headers

| Header | GET | POST | Description |
| --- | --- | --- | --- |
| `Accept: application/json` | recommended | recommended | Expected response representation |
| `Content-Type: application/json` | — | required | Exact signed request body |
| `X-Key-Id` | required | required | Non-secret credential identifier |
| `X-Timestamp` | required | required | Current Unix seconds |
| `X-Nonce` | required | required | New 16–128 ASCII characters per attempt |
| `Idempotency-Key` | — | required | Stable logical-operation key, max 128 ASCII characters |
| `X-Signature` | required | required | Strict Base64 Ed25519 signature |

Canonical UTF-8 bytes:

```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
```

Sign the path beginning at `/v1`, including its query string. A `GET` uses an
empty idempotency line and SHA-256 of zero body bytes. A `POST` must send the
same JSON bytes that were hashed; do not encode the object again after signing.

## Resource conventions

- Monetary JSON fields are decimal strings with up to six fractional digits.
  Never parse them with binary floating point.
- `order_ref` and `withdrawal_ref` are Merchant-controlled stable references:
  `^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`.
- After validating a reference against that grammar, append it to the resource
  path without percent-encoding any allowed character. The exact literal path
  is signed and sent; for example `ORDER:123` stays `/v1/orders/ORDER:123`.
- Timestamps are RFC 3339 UTC values.
- `X-Request-Id` is returned for support correlation. Error bodies also contain
  `request_id`.
- Exact `POST` replay returns the original resource with
  `idempotent_replay: true`. Replay identity is the Merchant reference plus its
  immutable request content. Reusing a reference with different content
  returns `409`.
- `Idempotency-Key` is required and signed on every `POST`, but the current
  public resource repository does not persist it as a separate deduplication
  key. Therefore a retry must preserve both the same key and the same Merchant
  reference/body; do not rely on the header to deduplicate two different
  references.
- The maximum HTTP request body is 64 KiB. The optional `metadata` value is
  canonicalized for replay comparison and must encode to at most 4 KiB of
  UTF-8 JSON.
- A withdrawal destination is a Base58Check TRON account address beginning with
  `T`; do not submit the USDT token-contract address as the destination.
- Unknown fields are not part of the public contract. Clients should send only
  documented request fields and ignore unknown response fields added in a
  backward-compatible version.
- Every response has `Cache-Control: no-store`, `X-Content-Type-Options:
  nosniff`, and an `X-Request-Id` correlation value.
- The Nile default API budget is 300 requests per minute per credential with a
  burst of 30. The actual environment budget is part of onboarding and is not
  currently returned by `/v1/capabilities`.

## Recommended integration sequence

1. Complete the [Merchant Onboarding Guide](merchant-onboarding-guide.md) and
   pin the API and webhook identities separately.
2. Call `GET /v1/capabilities` and refuse to start if the network, asset, or
   decimals do not match the Merchant environment.
3. Implement exact-byte signing and reference-based idempotent retry behavior.
4. Implement the callback receiver, unique event inbox, and polling recovery.
5. Run one real Nile deposit through confirmation and collection.
6. Run automatic and manual Merchant-final-sign withdrawal cases.
7. Rotate to separate Mainnet credentials only after the Nile acceptance
   evidence is approved.

## `GET /v1/capabilities`

Check the authenticated environment and immutable API capabilities.

Scope: active credential; no resource scope required.

```bash
curl --cert "$TRC20_MTLS_CERT_FILE" --key "$TRC20_MTLS_KEY_FILE" \
  -H "X-Key-Id: $TRC20_API_KEY_ID" \
  -H "X-Timestamp: $X_TIMESTAMP" \
  -H "X-Nonce: $X_NONCE" \
  -H "X-Signature: $X_SIGNATURE" \
  "$TRC20_API_BASE_URL/v1/capabilities"
```

`200 OK`:

```json
{
  "network": "nile",
  "asset": "USDT_TRC20",
  "token_decimals": 6,
  "order_ttl_minutes": 180,
  "withdrawals_require_approval": true,
  "arbitrary_signing_supported": false
}
```

## `POST /v1/orders`

Create a deposit order and allocate its one-time USDT-TRC20 address.

- Scope: `orders:write`
- Success: `201 Created`
- Exact replay: `200 OK`

Request:

```json
{
  "order_ref": "ORDER-20260720-0001",
  "expected_amount": "25.500000",
  "metadata": {"member_id": 12345}
}
```

Response:

```json
{
  "order_ref": "ORDER-20260720-0001",
  "currency": "USDT_TRC20",
  "deposit_address": "TExampleOneTimeDepositAddress1111111",
  "expected_amount": "25.500000",
  "received_total": "0.000000",
  "status": "pending",
  "created_at": "2026-07-20T08:00:00Z",
  "expires_at": "2026-07-20T11:00:00Z",
  "idempotent_replay": false
}
```

Show the returned address, exact amount, asset, and network to the payer. Do not
reuse it for another order.

## `GET /v1/orders/{order_ref}`

Retrieve the authoritative current deposit-order state.

- Scope: `orders:read`
- Success: `200 OK`
- Unknown reference: `404 Not Found`

```bash
GET /v1/orders/ORDER-20260720-0001
```

Order statuses:

| Status | Meaning |
| --- | --- |
| `pending` | No solidified qualifying transfer yet |
| `partially_paid` | Solidified received amount is below expected amount before expiry |
| `paid` | Expected amount reached |
| `underpaid` | Order expired below expected amount |
| `overpaid` | Solidified amount exceeds the configured overpayment boundary |
| `expired` | Expired with no confirmed payment |
| `manual_review` | Late, conflicting, or otherwise quarantined payment |

Poll this endpoint to recover from a delayed or missed callback.

## `POST /v1/withdrawals`

Create a withdrawal intent. This endpoint never signs or broadcasts a TRON
transaction synchronously.

- Scope: `withdrawals:write`
- Success: `202 Accepted`
- Exact replay: `200 OK`

Request:

```json
{
  "withdrawal_ref": "WD-20260720-0001",
  "merchant_user_ref": "member-12345",
  "destination_address": "<USER_CONTROLLED_TRON_ADDRESS>",
  "amount": "10.000000",
  "metadata": {"source": "aurora-h5"}
}
```

`merchant_user_ref` is required. Use a stable opaque ID from your own
user/account system so the Merchant signer can identify the requester in the
Portal. Do not send names, email addresses, phone numbers, or other direct
personal data.

Response:

```json
{
  "withdrawal_ref": "WD-20260720-0001",
  "merchant_user_ref": "member-12345",
  "currency": "USDT_TRC20",
  "destination_address": "<USER_CONTROLLED_TRON_ADDRESS>",
  "amount": "10.000000",
  "status": "scheduled",
  "tx_id": null,
  "scheduled_at": "2026-07-20T08:05:00Z",
  "created_at": "2026-07-20T08:00:00Z",
  "updated_at": "2026-07-20T08:00:00Z",
  "idempotent_replay": false
}
```

Policy may instead route the intent to `awaiting_approval`, `manual_review`, or
`rejected`. Insufficient Gateway withdrawal-wallet TRX or USDT pauses automatic
processing; the intent remains recoverable and is not broadcast.

## `GET /v1/withdrawals/{withdrawal_ref}`

Retrieve the authoritative current withdrawal state.

- Scope: `withdrawals:read`
- Success: `200 OK`
- Unknown reference: `404 Not Found`

Externally visible states can include:

| Status | Meaning | Terminal | Merchant action |
| --- | --- | --- | --- |
| `received` | Intent was stored and is entering initial risk evaluation | No | Poll; this state is normally brief |
| `validating` | Risk evaluation is waiting on fresh/transient evidence | No | Poll with backoff; do not create a replacement withdrawal |
| `scheduled` | Accepted for automatic processing at or after `scheduled_at` | No | Wait for callback or poll |
| `awaiting_approval` | Gateway policy requires its configured approval workflow | No | Wait; this is not the Merchant final-sign button |
| `approved` | Required Gateway policy approval is complete | No | Wait |
| `reserved` | Withdrawal-wallet liquidity is reserved | No | Wait |
| `signing` | Gateway signing work is in progress | No | Wait |
| `prepared` | Exact signed transaction bytes and transaction ID are persisted | No | Wait |
| `broadcast` | Persisted transaction was submitted to Nile | No | Wait for a solidified receipt |
| `retry` | A recoverable preparation or broadcast result is being reconciled | No | Poll with backoff; do not create a replacement withdrawal |
| `manual_review` | Gateway safety control stopped automatic progress | No | Contact Gateway operations with `X-Request-Id` and reference |
| `confirmed` | Successful solidified on-chain receipt | Yes | Complete the Merchant withdrawal exactly once |
| `rejected` | Policy rejected the intent before transfer | Yes | Show a terminal failure; create a new reference only for a new request |
| `failed` | Terminal on-chain or processing failure was proven | Yes | Reconcile, then follow Merchant support policy |

Only `confirmed` means a successful, solidified TRON receipt. When available,
`tx_id` is the TRON transaction ID. Poll this endpoint to recover from a
delayed or missed callback.

`processing` is not a public withdrawal status. The Portal can additionally display
`awaiting_merchant_authorization` while a separate Merchant final-sign workflow
is held for a human approver. That Portal projection is not currently returned
by `GET /v1/withdrawals/{withdrawal_ref}`; the API resource continues to expose
the underlying Gateway withdrawal status. Merchant approval users act in the
Portal, while Merchant server integrations continue polling and consuming
signed callbacks.

## Signed webhooks

The Gateway posts the exact JSON body to the pre-registered HTTPS callback URL
and does not follow redirects.

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

The callback URL is tenant onboarding configuration, not a field in
`POST /v1/orders` or `POST /v1/withdrawals`. Before integration testing, the
Merchant supplies one HTTPS receiver URL and the complete event subscription
list to the Gateway operator. The operator versions that configuration with
`trc20-admin set-webhook-endpoint`; the active row in `webhook_endpoints` is
the sole delivery destination. A Merchant API request cannot override it.

Delivery is asynchronous but transactionally coupled to the state change: the
Gateway writes the lifecycle change and its outbox event in the same database
transaction, and the callback worker normally claims pending events on its
next poll (up to about two seconds while idle). Network latency and retries
mean this is not a real-time SLA. Return `2xx` only after the Merchant platform
commits the event; otherwise the Gateway retries with exponential backoff
(maximum one hour between attempts) and dead-letters after 12 failed attempts.
Always use the corresponding `GET` resource endpoint as recovery source of
truth.

Different outbox events can be delivered concurrently and therefore arrive out
of order. Never infer lifecycle order from HTTP arrival time.

The emitted catalog is exactly `deposit.confirmed`, `order.expired`,
`sweep.confirmed`, `sweep.manual_review`, `withdrawal.status_changed`, and
`withdrawal.confirmed`. Only events present in the active endpoint version are
delivered. Adding a newly introduced event requires the Gateway operator to run
`set-webhook-endpoint` again with the complete replacement event list; omitted
events are unsubscribed.

| Header | Description |
| --- | --- |
| `X-Webhook-Id` | Stable delivery deduplication key |
| `X-Webhook-Timestamp` | Unix seconds |
| `X-Webhook-Signature` | Strict Base64 Ed25519 signature by the Gateway |

No webhook signing-key ID is currently sent. During key rotation, the Merchant
must temporarily accept both independently pinned Gateway public keys while the
operator coordinates the cutover. Remove the old key only after the documented
overlap and delivery verification complete.

Canonical webhook bytes:

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

Verification order:

1. Read the raw HTTP body bytes once.
2. Require all three headers and reject timestamps outside five minutes.
3. Strict-Base64 decode the 64-byte signature.
4. Verify with the separately pinned Gateway webhook public key.
5. Parse JSON; require body `event_id` to equal `X-Webhook-Id`.
6. In one database transaction, insert the event ID under a unique constraint
   and apply the state change.
7. Return `2xx` only after commit. A repeated event returns `2xx` without
   repeating the business change.

For `withdrawal.status_changed`, store the greatest `transition_version` seen
for each `withdrawal_ref`. Ignore a lower version after signature verification;
treat the same version with different verified content as a security incident.
Do not require the first event observed by a new consumer to be version 1.

If subscribed to both terminal formats, `withdrawal.status_changed` with
`status: confirmed` and `withdrawal.confirmed` describe the same business
completion but have different event IDs. `event_id` deduplication alone is not
enough: the Merchant must also make completion idempotent by
`withdrawal_ref` and verify a consistent `tx_id`. Use
`withdrawal.status_changed` as the canonical lifecycle stream; treat
`withdrawal.confirmed` as a compatibility terminal signal.

### `deposit.confirmed`

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

### `order.expired`

```json
{
  "event": "order.expired",
  "event_id": "order.expired:42",
  "order_ref": "ORDER-20260720-0001",
  "currency": "USDT_TRC20",
  "received_total": "0.000000",
  "status": "expired"
}
```

### `sweep.confirmed`

The Gateway emits this after the confirmed deposit funds have been swept to the
Merchant's versioned collection destination.

```json
{
  "event": "sweep.confirmed",
  "event_id": "sweep:42",
  "order_ref": "ORDER-20260720-0001",
  "currency": "USDT_TRC20",
  "amount": "25.500000",
  "amount_atomic": 25500000,
  "tx_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "destination_address": "<MERCHANT_COLLECTION_ADDRESS>",
  "collection_route_version": 3
}
```

### `sweep.manual_review`

The Gateway emits this instead of creating a sweep when an otherwise confirmed
deposit has no usable collection-route snapshot.

```json
{
  "event": "sweep.manual_review",
  "event_id": "sweep-review:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:0",
  "order_ref": "ORDER-20260720-0001",
  "currency": "USDT_TRC20",
  "amount": "25.500000",
  "reason": "order missing collection route snapshot"
}
```

### `withdrawal.status_changed`

This occurrence-versioned event reports every public lifecycle transition.
`event_id` is unique per transition occurrence; `transition_version` is
strictly increasing for one withdrawal.

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

For a newly created withdrawal, the initial transition out of `validating` has
`previous_status: null` and `transition_version: 1`. Migrated or already
in-flight withdrawals can first appear at a later status and version; consumers
must not assume null/1 for every first-seen event. Internal routing states are
not exposed as callbacks.

### `withdrawal.confirmed`

This legacy terminal event remains available alongside the generic confirmed
status change during migration.

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

## HTTP errors and retry policy

| Status | Meaning | Merchant action |
| --- | --- | --- |
| `400` | Invalid path/body/header shape | Fix the request; do not blindly retry |
| `401` | Bad/stale signature, unknown credential, or denied source IP | Check clock, exact bytes, key ID, signature, and onboarded source CIDR |
| `403` | Valid credential lacks the required scope | Correct the credential scope |
| `404` | Tenant-scoped resource not found | Check the Merchant reference |
| `409` | Reused nonce, immutable reference conflict, or unavailable address | Query the reference and reconcile before retrying |
| `413` | Request body exceeds 64 KiB | Reduce the request; metadata itself is limited to 4 KiB |
| `429` | Rate limited | Wait for `Retry-After` |
| `500` | Unexpected service/dependency failure | Retry with exponential backoff and jitter |
| `503` | Readiness check reports a dependency unavailable | Do not send business traffic until healthy |

For an ambiguous `POST`, reuse the same logical reference, exact body, and
idempotency key, but generate a fresh timestamp, nonce, and signature. Never
create a new reference merely because the HTTP response was lost.

## Troubleshooting

- **TLS handshake fails:** verify the dedicated Merchant API hostname, client
  certificate chain, unencrypted runtime-readable key, and certificate
  validity.
- **HTTP 401:** compare the uppercase method, path including query, all six
  canonical lines, exact body SHA-256, synchronized clock, unique nonce, Key
  ID, and strict Base64 Ed25519 signature.
- **HTTP 409 on retry:** do not mutate JSON serialization or the idempotency
  key; query the original Merchant reference first.
- **Missing callback:** query the resource endpoint, confirm the callback
  handler returns 2xx only after commit, and verify all six subscribed event
  names.
- **Withdrawal paused:** the Gateway may be waiting for policy approval, fresh
  reconciliation evidence, TRX energy runway, or USDT liquidity; poll until
  the state advances.
