Merchant API

Not a developer? See the Merchant Guide — a plain-language version for operations and support teams.

This document describes the API endpoints available to merchants via API key authentication.

HOST:https://api.copay888.com/

Authentication

All requests must include your API key in the X-API-Key header, like copay_key_xxxxxx:

X-API-Key: <your-api-key>

Getting an API Key

  1. Log in to the merchant portal
  2. Navigate to Settings → Developer
  3. Click Generate API Key
  4. Copy the key — it is only shown once

To rotate your key, click Regenerate. To disable API access, click Revoke.


Endpoints

Two Ways to Collect: Orders vs Payers

The platform supports two collection modes, and you can use both at once:

Payment OrdersPayer Deposit Addresses
Best forOne-off payments: checkout, per-transaction settlementBalance / top-up businesses: a fixed deposit address per end user
How to createCall POST /api/orders for every paymentCall POST /api/payers once per user; the address is reused long-term
AmountFixed per order, with expiry (default 30 minutes)Open-ended — users can send any amount at any time
Hosted checkoutResponse includes a checkout_url page you can send straight to the payerNone (users transfer directly to the address)
Who pays the feeThe payer, by default (payable_amount = amount + fee_amount; flip with isIncludeFee)The merchant: each settled deposit is charged at your contracted rate, deducted automatically from your merchant balance
Minimum fee per transaction0.300.30
Reconciliation fieldpspOrderCode (your own order ID, echoed back; query via GET /api/orders?pspOrderId=)customPayerId (your own user ID, echoed back as custom_payer_id in deposit notifications)
Webhookorder.status.update (order status changes)order.status.update (pushed when a deposit settles; data.data_type = "PaymentTransaction", data.acquiring_type = "TopUp", includes custom_payer_id and the amount)

In short: use Orders for pay-per-transaction commerce, and Payers for platforms where users top up a balance first. Both modes are charged at your contracted fee rate, with a 0.30 minimum per transaction.

Create Payment Order

POST /api/orders

Creates a new payment order for your merchant account.

Request Body

FieldTypeRequiredDescription
pspOrderCodestringYesYour internal order / transaction ID (max 64 chars). Echoed back as psp_order_code in every order response, and queryable via GET /api/orders?pspOrderId=... — use it to correlate webhooks and reconcile with your own records
payableCurrencystringYesCurrency token (e.g. ETH_USDT, TRON_USDT)
amountstringYesOrder amount, denominated in payableCurrency. Its meaning depends on isIncludeFee
isIncludeFeebooleanNoWhether amount already includes the fee. Defaults to false. See "Amount semantics" below
expiredInintegerNoOrder expiry in seconds, range 110800. Defaults to 1800 (30 minutes)

Note: All amounts are denominated in payableCurrency — there is no fiat pricing or exchange-rate conversion. The response's payable_amount is the total the payer pays.

⚠️ The payment must match payable_amount exactly. Underpaying (even by one cent) or paying after the order expires means the order cannot complete and funds are not refunded automatically — contact support (Telegram @copay8888) with the transaction hash for manual handling. Always show the payer the exact payable_amount and address from the response.

Amount semantics (isIncludeFee):

  • false (default) — amount is what the merchant nets. The payer pays amount + fee_amount. For example, if a merchant submits 100.00 with isIncludeFee=false and the fee is 0.30, the payer is charged 100.30.
  • trueamount is what the payer pays; the merchant settles amount minus the fee. The request is rejected with 400 if amount < fee_amount.

Fee: The platform computes the fee automatically as order amount × your contracted fee rate, rounded up to 2 decimal places, with a minimum of 0.30 per order (denominated in payableCurrency). You do not — and cannot — supply feeAmount in the request. The resulting fee is returned as fee_amount in the response.

Example Request

curl -X POST https://api.copay888.com/api/orders \
  -H "X-API-Key: copay_key_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "pspOrderCode": "ORDER-2024-001",
    "payableCurrency": "ETH_USDT",
    "amount": "100.00",
    "expiredIn": 1800
  }'

Example Response

The data object is the Order resource. Key fields:

{
  "data": {
    "order_id": "5001",
    "psp_order_code": "ORDER-2024-001",
    "status": "Pending",
    "fee_amount": "0.50",
    "payable_currency": "ETH_USDT",
    "payable_amount": "100.50",
    "chain_id": "ETH",
    "receive_address": "0x1234...abcd",
    "received_token_amount": "0",
    "expired_at": 1711324800,
    "created_timestamp": 1711323000,
    "updated_timestamp": 1711323000,
    "checkout_url": "https://copay888.com/pay/a1b2c3d4e5f6..."
  }
}

Hosted checkout (recommended): checkout_url in the response is a Copay-hosted payment page. Forward it to your payer as-is — it renders the exact payable_amount, the receive address, a QR code, and a countdown, and updates automatically once paid. If you render your own UI instead, always show the payer payable_amount (not the amount you sent): orders have zero underpayment tolerance.

receive_address is the on-chain address you should display to the payer. status transitions through Pending → Processing → Completed (or Expired / Underpaid).


List Pay-in Orders

GET /api/orders

Returns the pay-in orders that belong to your merchant account. The merchant_id is derived from your API key, so cross-merchant access is impossible.

Query Parameters

FieldTypeRequiredDescription
limitintegerNoMax items to return, range 150. Defaults to 10
beforestringNoCursor for backward pagination. Use the pagination.before value from a previous response
afterstringNoCursor for forward pagination. Use the pagination.after value from a previous response
statusesstringNoComma-separated status filter, e.g. Pending,Processing. Allowed values: Pending, Processing, Completed, Expired, Underpaid
pspOrderIdstringNoFilter by your PSP order code (the pspOrderCode you supplied when creating the order)

Example Request

curl -G https://api.copay888.com/api/orders \
  -H "X-API-Key: copay_key_abc123..." \
  --data-urlencode "statuses=Pending,Processing" \
  --data-urlencode "limit=20"

Example Response

{
  "data": [
    {
      "order_id": "5001",
      "psp_order_code": "ORDER-2024-001",
      "status": "Pending",
      "fee_amount": "0.50",
      "payable_currency": "ETH_USDT",
      "payable_amount": "100.50",
      "chain_id": "ETH",
      "receive_address": "0x1234...abcd",
      "received_token_amount": "0",
      "expired_at": 1711324800,
      "created_timestamp": 1711323000,
      "updated_timestamp": 1711323000
    }
  ],
  "pagination": {
    "before": "",
    "after": "RqeEoTkgKG5rpzqYzg2Hd3szmPoj2cE7w5jWwShz3C1vyGSAk",
    "total_count": 10000
  }
}

To fetch the next page, pass pagination.after from the current response as the after query parameter on the next call. When pagination.after is empty, there are no more pages.


Create Payer

POST /api/payers

Creates a new payer and their first top-up deposit address.

Fee: In payer mode, every settled deposit is charged a platform fee at your contracted rate (minimum 0.30 per deposit, denominated in the deposit currency). The full deposit amount is credited to your merchant balance first; the fee is then deducted from the balance automatically.

⚠️ Crediting payer deposits: Deposit webhooks carry a normalized summary block (kind: "payer_topup"). Treat summary.settled === true as the one and only settlement signal — look up your user via summary.custom_payer_id, credit summary.amount, and dedupe on summary.transaction_id. Do not rely on data.status — the upstream settlement event may carry a stale "Confirming" snapshot. settled: false means the deposit is still confirming (display-only).

Request Body

FieldTypeRequiredDescription
namestringYesDisplay name for the payer
tokenIdstringYesToken for the first address (e.g. ETH_USDT)
customPayerIdstringNoCustom identifier; auto-generated UUID if omitted

Example Request

curl -X POST https://api.copay888.com/api/payers \
  -H "X-API-Key: copay_key_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Alice",
    "tokenId": "ETH_USDT"
  }'

Example Response

The data object is the TopUpAddress resource:

{
  "data": {
    "address": "0x1234567890abcdef...",
    "payer_id": "P20250619T0310056d7aa",
    "custom_payer_id": "550e8400-e29b-41d4-a716-446655440000",
    "merchant_id": "M1001",
    "token_id": "ETH_USDT",
    "chain": "ETH",
    "min_amount": "0.1",
    "developer_fee_rate": "0.01",
    "created_timestamp": 1744689600,
    "updated_timestamp": 1744689600
  }
}

If a top-up address already exists for this customPayerId on the requested chain, the existing address is returned (idempotent).


List Deposits (reconciliation)

GET /api/deposits
GET /api/payers/{customPayerId}/deposits

The pull side of payer mode. Webhooks are push-only — if your callback endpoint is down or mishandles a delivery, that notification is not retried forever. These endpoints let you ask at any time: "did Copay see this deposit, and what is its status?"

Recommended pattern (fallback polling): keep crediting off the webhook's summary.settled === true as your primary path. In addition, run a low-frequency poll (e.g. every 5–10 minutes) that fetches recent status=SETTLED deposits and diffs their transaction_id against what you have credited locally — the difference is exactly your missed callbacks. This replaces per-chain on-chain lookup fallbacks and works uniformly across all chains.

The second endpoint filters to one payer by the customPayerId you supplied at creation. Payers belonging to another merchant always return 404.

Query Parameters (identical for both endpoints)

ParamTypeDescription
limitintPage size, default 50, max 200
cursorstringnext_cursor from the previous page
statusstringPENDING (seen, not settled) or SETTLED
tokenIdstringFilter by token, e.g. BSC_USDT
transactionHashstringExact-match one deposit by on-chain hash
from / toISO 8601Filter on first-seen time (inclusive)

Example Request

curl "https://api.copay888.com/api/deposits?status=SETTLED&limit=50" \
  -H "X-API-Key: copay_key_abc123..."

Example Response

{
  "data": [
    {
      "transaction_id": "52ca9ea9-...",
      "transaction_hash": "0x34adaf...",
      "payer_id": "P20250619T0310056d7aa",
      "custom_payer_id": "550e8400-e29b-41d4-a716-446655440000",
      "token_id": "BSC_USDT",
      "chain": "BSC",
      "amount": "1099.99",
      "status": "SETTLED",
      "provider_status": "Confirming",
      "cobo_status": "Confirming",
      "from_address": "0xabc...",
      "to_address": "0xdef...",
      "settled_at": "2026-08-10T14:03:11.000Z",
      "first_seen_at": "2026-08-10T13:58:40.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Field notes

  • status is the only settlement signal: SETTLED means credit-safe. provider_status carries a raw channel-side snapshot that may lag (see the Create Payer note above) — use it for support triage only, never for crediting. The legacy alias cobo_status returns the same value and is deprecated; migrate to provider_status.
  • transaction_id matches summary.transaction_id in webhooks, so the two channels reconcile and dedupe against each other directly.
  • PENDING means the deposit has been seen but not yet settled — useful for showing your user a "confirming" state early.

Create Payout

POST /api/payouts

Creates a crypto withdrawal from your merchant account.

Request Body

FieldTypeRequiredDescription
tokenIdstringYesToken to pay out (e.g. ETH_USDT)
amountstringYesAmount as a string decimal
recipientAddressstringYesDestination wallet address
recipientTokenIdstringYesToken at destination. Same currency as tokenId is required and availability follows the enabled network configuration.

Payout fee: A fee is charged to the platform from your balance, separately from the payout amount, based on the source token (tokenId) vs the destination token (recipientTokenId):

  • Same currency, same chain (e.g. TRON_USDTTRON_USDT) — your contracted flat fee per payout
  • Same currency, cross-chain (e.g. ETH_USDTTRON_USDT) — 500 ~ 300,000 per transfer, fee = amount × your contracted rate, min 20, capped at 200
  • Different currency (e.g. ETH_USDCETH_USDT) — rejected with 400 (PAYOUT_CURRENCY_MISMATCH); payouts between different currencies are not supported.

⚠️ Arrival times: Minimum payout is 1 USDT. Payouts up to and including 500 USDT transfer on-chain right after platform confirmation, typically within minutes; payouts above 500 USDT require admin approval, normally 15–20 minutes, then transfer automatically. Status Transferring means the on-chain transfer is in flight.

Idempotency (strongly recommended)

Pass an Idempotency-Key header (any unique string, e.g. a UUID) with each payout request. Retrying with the same key and same payload safely resumes the same operation instead of creating a duplicate payout; the same key with a different payload is rejected with 409. The key is echoed back in the Idempotency-Key response header.

Example Request

curl -X POST https://api.copay888.com/api/payouts \
  -H "X-API-Key: copay_key_abc123..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f9c2b4e-your-unique-key" \
  -d '{
    "tokenId": "ETH_USDT",
    "amount": "100.00",
    "recipientAddress": "0xdef...",
    "recipientTokenId": "ETH_USDT"
  }'

Example Response

The payout resource is wrapped in an operation envelope with the current state:

{
  "data": {
    "payout_id": "123e4567-e89b-12d3-a456-426614174004",
    "request_id": "payout-1730000000000-ab12cd",
    "payout_channel": "Crypto",
    "source_account": "M1001",
    "recipient_info": {
      "address": "0xdef...",
      "token_id": "ETH_USDT"
    },
    "status": "Pending",
    "created_timestamp": 1744689600,
    "updated_timestamp": 1744689600
  },
  "operationId": "8b1f6a2e-...",
  "idempotencyKey": "7f9c2b4e-your-unique-key",
  "state": "PAYOUT_DONE"
}

Admin approval for large payouts

Payouts above 500 (token units) require approval by the platform operations team before any funds move — a payout of exactly 500 does not. In that case the creation call returns immediately with:

{
  "data": {
    "approval_required": true,
    "message": "Payouts above 500 require admin approval."
  },
  "operationId": "8b1f6a2e-...",
  "idempotencyKey": "7f9c2b4e-your-unique-key",
  "state": "AWAITING_APPROVAL"
}
  • Once approved, the payout executes automatically through the normal pipeline — track it via the payout.status.update webhook.
  • If rejected, replaying the same Idempotency-Key returns 409 with code PAYOUT_REJECTED and the rejection reason.
  • Possible state values: AWAITING_APPROVAL, REJECTED, FEE_PENDING, FEE_DONE, PAYOUT_PENDING, PAYOUT_DONE, PAYOUT_REFUNDED, NEEDS_VERIFICATION, NEEDS_MANUAL_REVIEW.

Subscribe to the payout.status.update webhook to track status transitions through to Completed or Failed.


Webhooks

Webhooks let you receive real-time notifications when payment events occur in your merchant account. When an event fires, we POST a signed JSON payload to each of your registered endpoints that subscribes to that event type.

Setting Up

  1. Log in to the merchant portal and navigate to Settings → Developer → Webhook
  2. Click Register Endpoint, enter your URL and select the event types you want to receive
  3. Copy the signing secret (whsec_...) — it is shown only once at registration time. Store it securely.

Event Types

EventTriggered when
order.status.updateA payment order's status changes (created, confirmed, completed, failed, etc.)
payout.status.updateA payout's status changes

⚠️ One payer deposit sends two callbacks

This is the single most common integration mistake. Please read this section before writing your handler.

A single deposit produces two order.status.update deliveries, one per lifecycle stage:

Deliverysummary.settledMeaningWhat to do
1stfalseReceived on-chain, confirmingShow as "pending" — do not credit
2ndtrueSettled, funds availableCredit / fulfil / release entitlements now

Both deliveries carry the same summary.transaction_id and the same amount.

⚠️ The classic failure: running your crediting logic on both deliveries, which credits the user's balance, points or entitlements twice. The check must be if (payload.summary.settled === true) { credit() } — never "a callback arrived, therefore credit".

Three defences, all recommended:

  1. Gate on settlement — credit only when summary.settled === true
  2. Deduplicate — put a unique index on summary.transaction_id and process each exactly once (network retries redeliver the same event)
  3. Do not read data.status — a settlement event may carry a lagging "Confirming" snapshot. summary is the stable field we provide for exactly this reason; trust it instead.
// Correct handler
app.post('/webhook/copay', (req, res) => {
  res.sendStatus(200)                              // ack first, process async
  const s = req.body.summary
  if (!s || s.settled !== true) return             // still confirming — don't credit
  if (alreadyProcessed(s.transaction_id)) return   // idempotency
  credit(s.custom_payer_id, s.amount)
  markProcessed(s.transaction_id)
})

Delivery

Each webhook request is an HTTP POST to your endpoint with the following headers:

HeaderDescription
X-Webhook-SignatureHMAC-SHA256 signature (hex-encoded)
X-Webhook-TimestampUnix timestamp (seconds) when the request was signed
X-Webhook-Event-TypeEvent type string (e.g. order.status.update)

The body is a JSON object. The event_id field inside the payload is a globally unique identifier for the event.

Your endpoint must return a 2xx response within 5 seconds. Process any heavy work asynchronously after responding.

Delivery semantics: a non-2xx response or a timeout (5 s) counts as a failed delivery, and we retry automatically — 3 attempts total (immediately, +2 s, +8 s). After that the event is marked failed and can be redispatched by support. Still treat webhooks as real-time signals, not as the source of truth — always confirm state with GET /api/orders?pspOrderId=... before fulfilling (see the integration walkthrough below).

Payload Format

Every webhook body follows the same envelope:

{
  "event_id": "evt_9f2b1c8e7d3a4f01",
  "type": "wallets.transaction.succeeded",
  "data": {
    "wallet_id": "2f040e54-f40d-4848-b9cb-4c75b03b0676",
    "...": "transaction / order fields for this event"
  }
}
FieldDescription
event_idGlobally unique event ID — use it to deduplicate
typeThe upstream event that fired. Order events: payment.order.status.updated / payment.status.updated / payment.refund.status.updated or wallets.transaction.*; payout events: fee_station.transaction.*. The X-Webhook-Event-Type header always carries the mapped type you subscribed to (order.status.update / payout.status.update) — route on that header, don't enumerate type
dataThe transaction object for this event. Exact fields vary by event type and may evolve — do not build hard schema dependencies on data; extract what you need defensively

Recommended handling pattern — this makes your integration robust regardless of payload details:

  1. Verify the signature (below) and deduplicate by event_id.
  2. Return 200 immediately.
  3. Asynchronously call GET /api/orders?pspOrderId=<your-order-id> (or list recent orders) and act on the queried statusCompleted means settled funds.

Verifying Signatures

Always verify the signature before processing a webhook to confirm it came from our system.

Algorithm

signature = HMAC-SHA256(secret, timestamp + "." + rawBody)

Compare the hex output with the X-Webhook-Signature header. Use a constant-time comparison to prevent timing attacks.

Node.js example

const crypto = require('crypto')

function verifyWebhook(req, secret) {
  const timestamp = req.headers['x-webhook-timestamp']
  const signature = req.headers['x-webhook-signature']
  const rawBody = req.rawBody // Buffer or string — must be the unmodified request body

  // Reject stale requests (replay protection)
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    throw new Error('Timestamp too old')
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    throw new Error('Invalid signature')
  }
}

Python example

import hashlib
import hmac
import time

def verify_webhook(headers, raw_body: bytes, secret: str):
    timestamp = headers['X-Webhook-Timestamp']
    signature = headers['X-Webhook-Signature']

    # Reject stale requests (replay protection)
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError('Timestamp too old')

    message = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, signature):
        raise ValueError('Invalid signature')

Best Practices

  • Respond immediately. Return 2xx as soon as you receive the request and handle processing in the background.
  • Deduplicate by event_id. The same event may occasionally arrive more than once. Use event_id from the payload as an idempotency key.
  • Don't rely on webhooks alone. Even with automatic retries (3 attempts), delivery can fail if your endpoint stays unreachable. Poll GET /api/orders — filtered by pspOrderId or by statuses — as your reconciliation source of truth.
  • Reject old timestamps. Discard requests where X-Webhook-Timestamp is more than 5 minutes in the past to prevent replay attacks.
  • Use HTTPS. Register only https:// endpoints. We will not deliver to plain HTTP URLs.
  • Protect your secret. Never log or expose the whsec_* secret. If compromised, delete the endpoint and register a new one to get a fresh secret.

Integration Walkthrough (Sample)

A complete deposit (pay-in) integration, end to end.

Step 1 — Create an order with your own transaction ID

curl -X POST https://api.copay888.com/api/orders \
  -H "X-API-Key: copay_key_abc123..." \
  -H "Content-Type: application/json" \
  -d '{"pspOrderCode":"TX-10086","payableCurrency":"TRON_USDT","amount":"100.00"}'

Store the returned order_id, and show receive_address + payable_amount to your payer (the payer must send exactly payable_amount on the right chain before expired_at).

Step 2 — Receive the webhook (signal)

// Express example — raw body required for signature verification
app.post('/webhooks/copay', express.raw({ type: 'application/json' }), (req, res) => {
  verifyWebhook(req, WEBHOOK_SECRET)            // see "Verifying Signatures"
  const event = JSON.parse(req.body)
  if (seenBefore(event.event_id)) return res.sendStatus(200)  // dedupe
  res.sendStatus(200)                            // respond fast
  queue.push(event)                              // process async
})

Step 3 — Confirm via query (source of truth), then fulfil

// async worker
const r = await fetch(
  'https://api.copay888.com/api/orders?pspOrderId=TX-10086',
  { headers: { 'X-API-Key': API_KEY } }
).then((x) => x.json())

const order = r.data[0]
if (order && order.status === 'Completed') {
  fulfil('TX-10086')                             // credit your user
} else if (order && order.status === 'Underpaid') {
  flagForSupport('TX-10086', order.received_token_amount)
}

Withdrawal (payout) flow mirrors this: POST /api/payouts with an Idempotency-Key → if state is AWAITING_APPROVAL, wait (large payouts require operations approval) → track payout.status.update webhooks → on any doubt, treat your Idempotency-Key replay (same payload) as a safe status re-check.


Error Responses

StatusCodeDescription
401UnauthorizedMissing or invalid API key
400Bad RequestInvalid request body (validation error)
502Bad GatewayUpstream API error
503Service UnavailableAPI unreachable

Security Notes

  • Your API key has full access to your merchant account. Keep it secret.
  • The plaintext key is only shown once at generation time. Store it securely.
  • To revoke access immediately, use the Revoke button in the Developer settings page.
  • Rotate your key regularly as a security best practice.