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
- Log in to the merchant portal
- Navigate to Settings → Developer
- Click Generate API Key
- 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 Orders | Payer Deposit Addresses | |
|---|---|---|
| Best for | One-off payments: checkout, per-transaction settlement | Balance / top-up businesses: a fixed deposit address per end user |
| How to create | Call POST /api/orders for every payment | Call POST /api/payers once per user; the address is reused long-term |
| Amount | Fixed per order, with expiry (default 30 minutes) | Open-ended — users can send any amount at any time |
| Hosted checkout | Response includes a checkout_url page you can send straight to the payer | None (users transfer directly to the address) |
| Who pays the fee | The 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 transaction | 0.30 | 0.30 |
| Reconciliation field | pspOrderCode (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) |
| Webhook | order.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.30minimum per transaction.
Create Payment Order
POST /api/orders
Creates a new payment order for your merchant account.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
pspOrderCode | string | Yes | Your 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 |
payableCurrency | string | Yes | Currency token (e.g. ETH_USDT, TRON_USDT) |
amount | string | Yes | Order amount, denominated in payableCurrency. Its meaning depends on isIncludeFee |
isIncludeFee | boolean | No | Whether amount already includes the fee. Defaults to false. See "Amount semantics" below |
expiredIn | integer | No | Order expiry in seconds, range 1–10800. Defaults to 1800 (30 minutes) |
Note: All amounts are denominated in
payableCurrency— there is no fiat pricing or exchange-rate conversion. The response'spayable_amountis the total the payer pays.
⚠️ The payment must match
payable_amountexactly. 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 exactpayable_amountand address from the response.
Amount semantics (
isIncludeFee):
false(default) —amountis what the merchant nets. The payer paysamount + fee_amount. For example, if a merchant submits100.00withisIncludeFee=falseand the fee is0.30, the payer is charged100.30.true—amountis what the payer pays; the merchant settlesamountminus the fee. The request is rejected with400ifamount < 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.30per order (denominated inpayableCurrency). You do not — and cannot — supplyfeeAmountin the request. The resulting fee is returned asfee_amountin 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_urlin the response is a Copay-hosted payment page. Forward it to your payer as-is — it renders the exactpayable_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 payerpayable_amount(not theamountyou 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
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Max items to return, range 1–50. Defaults to 10 |
before | string | No | Cursor for backward pagination. Use the pagination.before value from a previous response |
after | string | No | Cursor for forward pagination. Use the pagination.after value from a previous response |
statuses | string | No | Comma-separated status filter, e.g. Pending,Processing. Allowed values: Pending, Processing, Completed, Expired, Underpaid |
pspOrderId | string | No | Filter 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.30per 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
summaryblock (kind: "payer_topup"). Treatsummary.settled === trueas the one and only settlement signal — look up your user viasummary.custom_payer_id, creditsummary.amount, and dedupe onsummary.transaction_id. Do not rely ondata.status— the upstream settlement event may carry a stale"Confirming"snapshot.settled: falsemeans the deposit is still confirming (display-only).
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name for the payer |
tokenId | string | Yes | Token for the first address (e.g. ETH_USDT) |
customPayerId | string | No | Custom 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 === trueas your primary path. In addition, run a low-frequency poll (e.g. every 5–10 minutes) that fetches recentstatus=SETTLEDdeposits and diffs theirtransaction_idagainst 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)
| Param | Type | Description |
|---|---|---|
limit | int | Page size, default 50, max 200 |
cursor | string | next_cursor from the previous page |
status | string | PENDING (seen, not settled) or SETTLED |
tokenId | string | Filter by token, e.g. BSC_USDT |
transactionHash | string | Exact-match one deposit by on-chain hash |
from / to | ISO 8601 | Filter 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
statusis the only settlement signal:SETTLEDmeans credit-safe.provider_statuscarries 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 aliascobo_statusreturns the same value and is deprecated; migrate toprovider_status.transaction_idmatchessummary.transaction_idin webhooks, so the two channels reconcile and dedupe against each other directly.PENDINGmeans 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
| Field | Type | Required | Description |
|---|---|---|---|
tokenId | string | Yes | Token to pay out (e.g. ETH_USDT) |
amount | string | Yes | Amount as a string decimal |
recipientAddress | string | Yes | Destination wallet address |
recipientTokenId | string | Yes | Token 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_USDT→TRON_USDT) — your contracted flat fee per payout- Same currency, cross-chain (e.g.
ETH_USDT→TRON_USDT) —500~300,000per transfer, fee = amount × your contracted rate, min20, capped at200- Different currency (e.g.
ETH_USDC→ETH_USDT) — rejected with400(PAYOUT_CURRENCY_MISMATCH); payouts between different currencies are not supported.
⚠️ Arrival times: Minimum payout is
1 USDT. Payouts up to and including500 USDTtransfer on-chain right after platform confirmation, typically within minutes; payouts above500 USDTrequire admin approval, normally 15–20 minutes, then transfer automatically. StatusTransferringmeans 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.updatewebhook. - If rejected, replaying the same
Idempotency-Keyreturns409with codePAYOUT_REJECTEDand the rejection reason. - Possible
statevalues: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
- Log in to the merchant portal and navigate to Settings → Developer → Webhook
- Click Register Endpoint, enter your URL and select the event types you want to receive
- Copy the signing secret (
whsec_...) — it is shown only once at registration time. Store it securely.
Event Types
| Event | Triggered when |
|---|---|
order.status.update | A payment order's status changes (created, confirmed, completed, failed, etc.) |
payout.status.update | A 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:
| Delivery | summary.settled | Meaning | What to do |
|---|---|---|---|
| 1st | false | Received on-chain, confirming | Show as "pending" — do not credit |
| 2nd | true | Settled, funds available | Credit / 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:
- Gate on settlement — credit only when
summary.settled === true - Deduplicate — put a unique index on
summary.transaction_idand process each exactly once (network retries redeliver the same event) - Do not read
data.status— a settlement event may carry a lagging"Confirming"snapshot.summaryis 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:
| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 signature (hex-encoded) |
X-Webhook-Timestamp | Unix timestamp (seconds) when the request was signed |
X-Webhook-Event-Type | Event 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-
2xxresponse 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 withGET /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"
}
}
| Field | Description |
|---|---|
event_id | Globally unique event ID — use it to deduplicate |
type | The 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 |
data | The 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:
- Verify the signature (below) and deduplicate by
event_id. - Return
200immediately. - Asynchronously call
GET /api/orders?pspOrderId=<your-order-id>(or list recent orders) and act on the queriedstatus—Completedmeans 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
2xxas 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. Useevent_idfrom 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 bypspOrderIdor bystatuses— as your reconciliation source of truth. - Reject old timestamps. Discard requests where
X-Webhook-Timestampis 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
| Status | Code | Description |
|---|---|---|
| 401 | Unauthorized | Missing or invalid API key |
| 400 | Bad Request | Invalid request body (validation error) |
| 502 | Bad Gateway | Upstream API error |
| 503 | Service Unavailable | API 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.