# PayTide Integration Guide

PayTide is a crypto payment gateway for overseas hosting/server-rental platforms.

## Flow

1. Merchant platform creates a payment order via PayTide API.
2. PayTide returns `paymentId`, `paymentUrl`, amount, chain and receiving address.
3. User opens `paymentUrl` and pays USDT on the selected chain, currently TRC20.
4. PayTide detects the on-chain transfer and waits for confirmations.
5. PayTide sends an HTTP POST callback to the merchant `notifyUrl`.
6. Merchant verifies callback signature and activates service.

## Create Payment

`POST /api/v1/payments`

### Recommended headers

```http
Content-Type: application/json
X-PT-Api-Key: <merchant apiKey>
X-PT-Timestamp: <unix milliseconds>
X-PT-Nonce: <unique random string>
X-PT-Signature: <HMAC-SHA256 signature>
Idempotency-Key: <unique key per merchant order>
```

Legacy headers are still accepted for compatibility:

```http
x-api-key: <merchant apiKey>
x-timestamp: <unix milliseconds>
x-signature: <legacy HMAC-SHA256 signature>
x-idempotency-key: <unique key per merchant order>
```

### Body

```json
{
  "merchantUserId": "user_1001",
  "merchantOrderId": "order_202605130001",
  "amount": "19.90",
  "currency": "USDT",
  "chain": "TRC20",
  "notifyUrl": "https://merchant.example.com/paytide/callback",
  "returnUrl": "https://merchant.example.com/orders/success",
  "expireMinutes": 30
}
```

Amounts are decimal strings. For USDT, PayTide stores integer raw units with 6 decimals, so `19.90` becomes `19900000` internally.

### Recommended API signature

Sign the exact raw JSON string sent as the HTTP request body.

```text
message = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + RAW_BODY
signature = HMAC_SHA256(apiSecret, message)
```

For payment creation:

```text
METHOD = POST
PATH = /api/v1/payments
```

Example Node.js signing code:

```js
import crypto from 'crypto';

const body = JSON.stringify({
  merchantUserId: 'user_1001',
  merchantOrderId: 'order_202605130001',
  amount: '19.90',
  currency: 'USDT',
  chain: 'TRC20',
  notifyUrl: 'https://merchant.example.com/paytide/callback',
  returnUrl: 'https://merchant.example.com/orders/success',
  expireMinutes: 30,
});

const timestamp = Date.now().toString();
const nonce = crypto.randomBytes(16).toString('hex');
const message = ['POST', '/api/v1/payments', timestamp, nonce, body].join('\n');
const signature = crypto.createHmac('sha256', apiSecret).update(message).digest('hex');

await fetch('https://paytide.xww.us/api/v1/payments', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-PT-Api-Key': apiKey,
    'X-PT-Timestamp': timestamp,
    'X-PT-Nonce': nonce,
    'X-PT-Signature': signature,
    'Idempotency-Key': 'order_202605130001',
  },
  body,
});
```

Security rules:

- Timestamp must be fresh; recommended window is 5 minutes.
- Nonce must be unique per API key within 10 minutes.
- Use `Idempotency-Key` for every create-payment request.
- Reusing the same idempotency key returns the same payment order.
- Never send API secret to frontend code.

### Legacy API signature

```text
signature = HMAC_SHA256(apiSecret, timestamp + "." + rawBody)
```

This remains supported, but new integrations should use the recommended signature format above.

### Response

```json
{
  "code": 0,
  "message": "ok",
  "data": {
    "paymentId": "PAY_xxx",
    "merchantId": "M_xxx",
    "merchantUserId": "user_1001",
    "merchantOrderId": "order_202605130001",
    "amount": "19.9",
    "amountRaw": "19900000",
    "detectedAmountRaw": null,
    "amountStatus": "exact",
    "currency": "USDT",
    "chain": "TRC20",
    "receiveAddress": "T...",
    "status": "PENDING",
    "paymentUrl": "https://paytide.xww.us/pay/PAY_xxx",
    "expireAt": "2026-05-13T01:00:00.000Z"
  }
}
```

## Query Payment

`GET /api/v1/payments/{paymentId}`

Returns current payment status.

Statuses:

- `PENDING` — waiting for transfer
- `PAID` — transaction seen but confirmations not enough
- `CONFIRMED` — confirmations reached; callback being sent
- `NOTIFIED` — merchant callback succeeded
- `EXPIRED` — order expired
- `CANCELLED` — cancelled manually
- `UNDERPAID` — transfer detected but amount is below requested amount

Amount status:

- `exact` — paid amount equals requested amount
- `underpaid` — paid amount is lower than requested amount
- `overpaid` — paid amount is higher than requested amount

## Callback

PayTide sends an HTTP POST request to `notifyUrl` after payment confirmation or expiration.

### Headers

```http
Content-Type: application/json
X-PayTide-Timestamp: <unix milliseconds>
X-PayTide-Signature: sha256=<HMAC-SHA256 signature>
X-PayTide-Event: payment.confirmed
X-PayTide-Delivery: <unique delivery id>
```

Legacy headers are also included:

```http
X-PaymentId: PAY_xxx
X-Timestamp: <unix milliseconds>
X-Signature: <HMAC-SHA256 signature>
```

### Confirmed callback body

```json
{
  "event": "payment.confirmed",
  "paymentId": "PAY_xxx",
  "merchantId": "M_xxx",
  "merchantUserId": "user_1001",
  "merchantOrderId": "order_202605130001",
  "status": "CONFIRMED",
  "amount": "19.9",
  "amountRaw": "19900000",
  "detectedAmountRaw": "19900000",
  "amountStatus": "exact",
  "currency": "USDT",
  "chain": "TRC20",
  "txHash": "...",
  "txid": "...",
  "fromAddress": "T_user_wallet",
  "toAddress": "T_receive_wallet",
  "confirmations": 20,
  "paidAt": "...",
  "confirmedAt": "..."
}
```

### Expired callback body

```json
{
  "event": "payment.expired",
  "paymentId": "PAY_xxx",
  "merchantId": "M_xxx",
  "merchantOrderId": "order_202605130001",
  "status": "EXPIRED",
  "amount": "19.9",
  "currency": "USDT",
  "chain": "TRC20",
  "expiredAt": "..."
}
```

### Callback signature verification

Sign and verify the exact raw callback body:

```text
expected = HMAC_SHA256(apiSecret, timestamp + "." + rawBody)
```

Example Node.js verification:

```js
import crypto from 'crypto';

function verifyPayTideCallback({ apiSecret, timestamp, rawBody, signature }) {
  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) return false;

  const normalized = signature.replace(/^sha256=/i, '');
  const expected = crypto
    .createHmac('sha256', apiSecret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(normalized, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Merchant server should:

1. Verify signature.
2. Check timestamp freshness, recommended <= 5 minutes.
3. Check `merchantOrderId` exists and amount matches.
4. Check `amountStatus`. Handle `underpaid` and `overpaid` explicitly.
5. Ensure idempotency: repeated callbacks for the same `paymentId` / `txid` must not double-credit.
6. Return HTTP 2xx only after the callback is processed successfully.

If the merchant endpoint returns non-2xx or times out, PayTide records the failure and retries later.


## Payout API (approval + dry-run)

PayTide supports merchant payout requests for operational review. This is intentionally **not** an automatic blockchain broadcasting API.

Current safety model:

1. Merchant creates a payout request with an API key that has `payouts:create` scope.
2. PayTide acquires a per-merchant PostgreSQL advisory transaction lock, then checks available ledger balance, chain status, address format and merchant risk settings.
3. Funds are reserved in ledger with `payout_reserved`; concurrent requests from the same merchant are serialized to prevent over-reserve.
4. Admin/risk team reviews the payout and can approve or reject it.
5. Finance can generate a dry-run quote and manually mark an externally executed transaction hash.
6. PayTide read-only verifies TRC20 USDT txHash before completion: destination address, amount, token contract and confirmations must match.
7. PayTide itself does not broadcast a transfer.

### Create payout

```http
POST /api/v1/payouts
Content-Type: application/json
X-PT-Api-Key: <merchant apiKey with payouts:create>
X-PT-Timestamp: <unix milliseconds>
X-PT-Nonce: <unique nonce>
X-PT-Signature: sha256=<signature>
Idempotency-Key: payout_order_001
```

The signature format is the same v2 API signature as payment creation, but the path is `/api/v1/payouts`.

```json
{
  "merchantOrderId": "payout_202605140001",
  "amount": "100.00",
  "currency": "USDT",
  "chain": "TRC20",
  "toAddress": "T...",
  "note": "daily settlement"
}
```

Response statuses:

- `pending_review` — created and reserved; waiting for risk/finance review.
- `approved` — risk approved.
- `dry_run_ready` — finance generated a non-broadcast quote.
- `completed` — finance manually recorded the external txHash.
- `rejected` — rejected and reserved balance released.
- `failed` — failed and reserved balance released.

Risk fields:

- `riskStatus`: `review_required`, `high_risk`, `approved`, `rejected`.
- `approvalStatus`: `pending`, `approved`, `rejected`.
- `dryRun`: `1` means no transfer was broadcast by PayTide.
- `verifyStatus`: `verified`, `pending`, `mismatch`, `unsupported`, `error`, or `unverified`.

### Query payout

```http
GET /api/v1/payouts/{payoutId}
X-PT-Api-Key: <merchant apiKey with payouts:read>
X-PT-Timestamp: <unix milliseconds>
X-PT-Nonce: <unique nonce>
X-PT-Signature: sha256=<signature>
```

For GET requests, sign an empty raw body:

```text
GET
/api/v1/payouts/PO_xxx
TIMESTAMP
NONCE

```

### Payout status webhooks

If the merchant webhook setting is enabled and its `events` list includes payout events, PayTide sends signed payout callbacks to the merchant webhook URL.

Supported payout events:

- `payout.created`
- `payout.approved`
- `payout.rejected`
- `payout.dry_run_ready`
- `payout.completed`
- `payout.failed`

Headers use the same signing model as payment callbacks:

```http
X-PayTide-Timestamp: <unix milliseconds>
X-PayTide-Signature: sha256=<signature>
X-PayTide-Event: payout.completed
X-PayTide-Delivery: PO_xxx-payout.completed-1
X-PayoutId: PO_xxx
```

Payload example:

```json
{
  "event": "payout.completed",
  "payoutId": "PO_xxx",
  "merchantId": "M_xxx",
  "merchantOrderId": "payout_202605140001",
  "status": "completed",
  "riskStatus": "approved",
  "approvalStatus": "approved",
  "amount": "100.00",
  "amountRaw": "100000000",
  "currency": "USDT",
  "chain": "TRC20",
  "toAddress": "T...",
  "txHash": "...",
  "dryRun": 0
}
```

Failed payout callbacks are recorded in callback logs and retried by the callback worker. Merchants can inspect callback logs and manually resend from the merchant console. Admin can also resend a payout callback with `POST /api/v1/admin/payouts/{payoutId}/resend-callback`.

## Risk and operations model

Payout risk checks currently include:

- Merchant payout freeze switch (`payoutsEnabled=0`).
- Address blacklist/reject hard block.
- Hourly payout frequency limit.
- Same-address daily payout limit.
- New merchant cold-start payout limit.
- Single payout limit.
- Daily payout projected limit.
- New payout address detection.
- Optional approved-address whitelist.
- Optional address cooldown.

Admin roles:

- `risk`: first payout approval, reject payouts and approve payout addresses.
- `finance`: second payout approval when amount reaches `dualApprovalLimit`, quote dry-run and mark externally executed txHash. The requester and approver cannot mark the same payout executed.
- `super_admin`: final approval when amount reaches `superApprovalLimit`, plus all permissions.

Approval thresholds are merchant-level risk settings. Below `dualApprovalLimit`, risk approval completes the payout approval. At/above `dualApprovalLimit`, both risk and finance must approve. At/above `superApprovalLimit`, risk, finance and super_admin must approve. Set a threshold to `0` to disable that level.

Every high-risk operation writes `audit_logs`. Operators should treat UI actions such as manual confirm, payout mark-executed, settlement complete and ledger backfill as financial operations requiring independent review.

## Plugin examples

PayTide ships lightweight plugin templates under `plugins/` and as a downloadable package:

- `public/downloads/paytide-plugins.zip`
- `plugins/dujiao/README.md` — Dujiao / Dujiao Next Epay-channel setup
- `plugins/xboard/PayTideEpay.php` — XBoard / V2Board-style payment class
- `plugins/whmcs/paytide_usdt_trc20.php` + callback — WHMCS gateway sample

These plugins use the Epay-compatible adapter documented in `docs/epay-compatibility.md`. Native V2 HMAC remains recommended for new custom systems.

## Webhook testing and replay operations

### Test callbacks

Before going live, ask the operator to send a Webhook Test from the PayTide admin merchant detail page.

The test callback uses:

```text
event = payment.test
paymentId = TEST-<timestamp>
```

It is signed with the same merchant secret and headers as production callbacks:

```http
X-PayTide-Timestamp: <unix milliseconds>
X-PayTide-Signature: sha256=<HMAC-SHA256(timestamp + "." + rawBody)>
X-PayTide-Event: payment.test
X-PayTide-Delivery: TEST-...-1
```

Merchant systems should treat `payment.test` as a sandbox/diagnostic event and must not credit users for it. Use it to verify:

1. HTTPS route is reachable.
2. Raw body is captured before JSON parsing.
3. Signature verification passes.
4. Timestamp freshness check works.
5. The endpoint returns HTTP 2xx only after processing succeeds.

### Callback replay

PayTide operators may replay a payment or payout callback when a merchant confirms that a callback was missed or lost.

Operational safeguards:

- replay requires an operator reason;
- replay is recorded in `audit_logs`;
- non-super-admin operators are rate-limited by a default 60 second replay cooldown;
- `super_admin` can bypass cooldown for emergency troubleshooting.

Merchant systems **must** be idempotent. A replayed callback may contain the same `paymentId`, `merchantOrderId`, `txHash` or payout identifier as a previously delivered callback. Never double-credit or double-settle based on repeated delivery.

### Callback logs and sanitization

PayTide stores callback delivery status, response body, error message and duration for troubleshooting. Response body and error message are sanitized before storage:

- API keys, tokens, secrets, passwords, authorization values and signatures are redacted;
- emails and phone-like numbers are partially masked;
- stored text is truncated to 2000 characters.

This protects PayTide logs, but merchant endpoints should still avoid returning sensitive data in webhook responses.
