One API, one hosted flow
CryptoPayIn prices an order in your selected presentment currency, locks verified fiat/USD and crypto/USD snapshots, allocates a dedicated deposit address and watches its own blockchain nodes for payment. Internal accounting, fees and balances remain in USD. Your backend receives a checkout URL immediately and signed lifecycle events afterward.
This is a live API. There is no sandbox prefix. Every successful creation allocates a real on-chain address. Use a small supported-currency amount for end-to-end tests and keep secret keys on your server.
How the integration fits together
Generate a secret once in Dashboard -> Developers.
POST the order currency, amount and selected asset.
Send the customer to the returned hosted URL.
Verify the HMAC and update your order idempotently.
Create your first payment
Generate an API key in the merchant dashboard, store the secret in an environment variable, then create a payment from your backend. The example uses ETH so it can be tested without choosing a token network.
curl --request POST https://cryptopayin.com/v1/payments \
--header "Authorization: Bearer $CPI_SECRET_KEY" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: order_1042" \
--data '{
"amount": 49.99,
"currency": "USD",
"asset": "ETH",
"order_ref": "order_1042",
"redirect_url": "https://shop.example/orders/1042/paid"
}'
const response = await fetch("https://cryptopayin.com/v1/payments", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.CPI_SECRET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order_1042"
},
body: JSON.stringify({
amount: 49.99, currency: "USD", asset: "ETH",
order_ref: "order_1042",
redirect_url: "https://shop.example/orders/1042/paid"
})
});
if (!response.ok) throw new Error(await response.text());
const payment = await response.json();
$payload = json_encode([
'amount' => 49.99, 'currency' => 'USD', 'asset' => 'ETH',
'order_ref' => 'order_1042',
'redirect_url' => 'https://shop.example/orders/1042/paid',
]);
$ch = curl_init('https://cryptopayin.com/v1/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('CPI_SECRET_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order_1042',
],
]);
$payment = json_decode(curl_exec($ch), true, flags: JSON_THROW_ON_ERROR);
import os, requests
response = requests.post(
"https://cryptopayin.com/v1/payments",
headers={
"Authorization": f"Bearer {os.environ['CPI_SECRET_KEY']}",
"Idempotency-Key": "order_1042",
},
json={
"amount": 49.99, "currency": "USD", "asset": "ETH",
"order_ref": "order_1042",
"redirect_url": "https://shop.example/orders/1042/paid",
}, timeout=15,
)
response.raise_for_status()
payment = response.json()Use the response
Persist the payment id beside your order, then redirect the customer to checkout_url. Do not calculate a crypto amount or deposit address yourself.
{
"id": "P-9F27C1E4KD",
"object": "payment",
"status": "created",
"amount": 49.99,
"amount_decimal": "49.99",
"amount_minor": 4999,
"currency": "USD",
"currency_minor_units": 2,
"amount_usd": 49.99,
"amount_usd_cents": 4999,
"fx_rate_usd": "1.000000000000",
"fx_source": "fixed:USD",
"fx_observed_at": "2026-07-17T13:00:00+00:00",
"fx_discrepancy_bps": 0,
"asset": "ETH",
"network": "mainnet",
"crypto_amount": "0.01388612",
"crypto_received": "0",
"deposit_address": "0x71b8c3d4700000000000000000000000000084e2",
"exchange_rate": "3600.00000000",
"exchange_rate_currency": "USD",
"exchange_rate_source": "median:cb,cg,cl",
"exchange_rate_source_count": 3,
"exchange_rate_observed_at": "2026-07-17T13:00:00+00:00",
"exchange_rate_discrepancy_bps": 12,
"confirmations": 0,
"confirmations_required": 12,
"order_ref": "order_1042",
"checkout_url": "https://cryptopayin.com/i/P-9F27C1E4KD",
"expires_at": "2026-07-17T13:30:00+00:00",
"created_at": "2026-07-17T13:00:00+00:00",
"completed_at": null
}Authentication
Every API request uses the secret key in an HTTP Bearer header. Secret keys begin with csk_live_. The companion cpk_live_ value is a public identifier for your dashboard and must not be used as the Bearer credential.
Authorization: Bearer csk_live_...Every endpointThe raw secret is returned only when the key is created. CryptoPayIn stores a password hash plus an indexed SHA-256 lookup, never the plaintext secret.
Create separate keys per application or environment and revoke them independently. An account can hold up to 50 active keys.
Never put a csk_live_ value in browser JavaScript, a mobile binary, a public repository or a checkout page.
A webhook endpoint can be account-wide or attached to one API key, keeping integrations isolated.
A missing, malformed, revoked or unknown secret returns 401 unauthorized. A suspended or closed merchant account is rejected the same way.
Idempotency
Send a unique Idempotency-Key on every payment creation. If your connection drops after submission, retry the identical JSON with the identical key: CryptoPayIn returns the original payment instead of allocating another address.
| Case | Result | HTTP |
|---|---|---|
| First use | Creates and returns a new payment. | 201 |
| Same key + same JSON | Returns the existing payment with Idempotent-Replayed: true. | 200 |
| Same key + different JSON | Rejects the request as idempotency_conflict. | 409 |
Keys are scoped to the API credential and may contain 1-128 letters, digits, dots, underscores, colons or dashes. A durable order UUID is a good choice.
API reference
Version 1 has five endpoints. Responses use UTF-8 JSON over HTTPS, and payment creation requires Content-Type: application/json. There is intentionally no browser CORS workflow: calls belong on your backend.
/v1/assetsDiscover available assets/v1/currenciesDiscover fiat presentment currencies/v1/paymentsCreate a payment/v1/paymentsList and filter payments/v1/payments/{id}Retrieve a paymentVersion 1 may receive backward-compatible fields and endpoints. A breaking contract change will use a new base path rather than silently changing /v1.
List assets
/v1/assetsBearer auth requiredUse this endpoint as the source of truth for checkout choices. It returns enabled catalog entries, current independently checked rates, minimum USD-equivalent amounts and whether the node plus price feed are ready. An entry can remain listed with available: false while a node is synchronizing or its price cannot be verified.
curl https://cryptopayin.com/v1/assets \
--header "Authorization: Bearer $CPI_SECRET_KEY"{
"object": "list",
"data": [{
"asset": "USDT",
"network": "TRC20",
"type": "token",
"decimals": 6,
"minimum_amount": 1,
"currency": "USD",
"base_confirmations": 19,
"available": true,
"rate_usd": "1.00000000",
"rate_source": "median:cb,cg,cl",
"rate_source_count": 3,
"rate_discrepancy_bps": 4,
"rate_status": "healthy",
"rate_updated_at": "2026-07-17T13:00:00+00:00"
}]
}Catalog and network identifiers
| Asset | Network value | Shorthand | Base confirmations |
|---|---|---|---|
| BTC | mainnet | BTC | 1 |
| ETH | mainnet | ETH | 12 |
| USDT | TRC20 / ERC20 | USDT.TRC20 | 19 / 12 |
| USDC | TRC20 / ERC20 | USDC.ERC20 | 19 / 12 |
| DAI | ERC20 | DAI | 12 |
| SHIB | ERC20 | SHIB | 12 |
| PEPE | ERC20 | PEPE | 12 |
| LTC | mainnet | LTC | 4 |
| TRX | mainnet | TRX | 19 |
| DOGE | mainnet | DOGE | 6 |
| XMR | mainnet | XMR | 10 |
Availability is dynamic. Do not hardcode the table above as a live allow-list. For multi-network symbols such as USDT and USDC, send network explicitly or use the ASSET.NETWORK shorthand.
List fiat currencies
/v1/currenciesBearer auth requiredReturns the enabled presentment currencies, their ISO precision and current USD conversion health. Only offer rows with available: true. USD is intrinsic; every other currency needs a fresh live quote and an independent reference check. minimum_amount converts the lowest configured enabled-asset floor into that currency; the chosen asset can require a higher amount, so always read GET /v1/assets too.
curl https://cryptopayin.com/v1/currencies \
--header "Authorization: Bearer $CPI_SECRET_KEY"{
"object": "list",
"accounting_currency": "USD",
"data": [{
"currency": "EUR",
"name": "Euro",
"symbol": "€",
"minor_units": 2,
"available": true,
"minimum_amount": "0.86",
"rate_usd": "1.160000000000",
"rate_source": "coinbase+ecb",
"rate_source_count": 2,
"rate_discrepancy_bps": 18,
"rate_updated_at": "2026-07-17T13:00:00+00:00",
"rate_reference_at": "2026-07-16T00:00:00+00:00"
}]
}Currency omitted on POST /v1/payments still means USD for backward compatibility. Zero-decimal currencies such as JPY reject fractional amounts. Treat catalogue minimums and rates as live data, never hardcoded constants.
Create a payment
/v1/payments120 requests / minute / keyCreates an invoice in the requested presentment currency, locks fresh verified fiat/USD and crypto/USD snapshots, computes the exact crypto amount and binds a dedicated on-chain deposit address.
Request body
| Field | Type | Requirement | Description |
|---|---|---|---|
| amount | number or decimal string | required | Value in currency, at that currency's ISO precision. Its locked USD equivalent must not exceed $1,000,000.00; asset minimums also apply. |
| currency | string | optional | Enabled 3-letter currency from GET /v1/currencies. Defaults to USD. |
| asset | string | required | Symbol such as ETH, or shorthand such as USDT.TRC20. |
| network | string | conditional | Required when a symbol exists on multiple networks. Example: ERC20. |
| order_ref | string | optional | Your order identifier, maximum 128 characters. Returned in API responses and events. |
| customer_email | string | optional | Valid email address, maximum 190 characters. Stored with the merchant payment record. |
| redirect_url | string | optional | HTTPS URL, maximum 255 characters, offered after successful checkout. |
Response fields
| Field | Type | Description |
|---|---|---|
| id | string | Stable payment identifier beginning with P-. |
| status | string | Current lifecycle state. |
| amount / amount_decimal / amount_minor | number / string / integer | Requested presentment value in convenient, exact-decimal and ISO minor-unit forms. |
| currency / currency_minor_units | string / integer | Locked presentment currency and its precision. |
| amount_usd / amount_usd_cents | number / integer | Immutable internal USD accounting value. |
| fx_rate_usd / fx_source / fx_observed_at | decimal string / string / ISO 8601 | Locked USD-per-presentment-unit snapshot and its audit metadata. |
| asset / network | string | Resolved on-chain asset. |
| crypto_amount | decimal string | Exact amount the customer must send. Never parse crypto decimals as binary floats. |
| crypto_received | decimal string | Total currently observed at the deposit address. |
| deposit_address | string | Dedicated address allocated for this payment. |
| exchange_rate | decimal string | Locked crypto/USD rate used to compute crypto_amount; this field keeps its original v1 meaning. |
| exchange_rate_source / exchange_rate_observed_at | string / ISO 8601 | Immutable crypto-rate audit snapshot. |
| confirmations | integer | Current network confirmations. |
| confirmations_required | integer | Threshold for this payment. Higher USD tiers may require additional confirmations. |
| checkout_url | URL | Hosted invoice to show the customer. |
| expires_at | ISO 8601 | Deadline for an unpaid invoice. |
| completed_at | ISO 8601 / null | Final settlement time when complete. |
Both live conversions are validated for freshness, source count and divergence before creation. If verification fails, creation returns an error instead of using a stale rate. The crypto amount is rounded upward at a useful asset precision, so rounding never leaves the merchant short.
List payments
/v1/payments240 requests / minute / keyReturns newest payments first for the authenticated merchant account. Use cursor pagination for reconciliation and exact filters to locate an order without walking the full history.
Query parameters
| Parameter | Default | Description |
|---|---|---|
| limit | 20 | Page size from 1 to 100. |
| starting_after | — | Payment ID returned as the previous page's next_cursor. |
| status | — | Exact lifecycle status such as pending, completed or expired. |
| order_ref | — | Exact merchant order reference, maximum 128 characters. |
curl "https://cryptopayin.com/v1/payments?status=completed&limit=20" \
--header "Authorization: Bearer $CPI_SECRET_KEY"{
"object": "list",
"data": [{
"id": "P-9F27C1E4KD",
"object": "payment",
"status": "completed",
"amount": 49.99,
"currency": "USD",
"asset": "ETH",
"network": "mainnet",
"crypto_amount": "0.01388612",
"crypto_received": "0.01388612",
"order_ref": "order_1042",
"confirmations": 12,
"confirmations_required": 12,
"completed_at": "2026-07-17T13:12:42+00:00"
}],
"has_more": true,
"next_cursor": "P-9F27C1E4KD"
}When has_more is true, pass next_cursor unchanged as starting_after. Unknown or repeated array-style query parameters are rejected instead of ignored.
Retrieve a payment
/v1/payments/{id}240 requests / minute / keyReturns the same payment object as creation with fresh status, received amount, transaction hash and confirmations. A key can only retrieve payments belonging to its merchant account.
curl https://cryptopayin.com/v1/payments/P-9F27C1E4KD \
--header "Authorization: Bearer $CPI_SECRET_KEY"Webhooks should drive normal order updates. Use retrieval to reconcile after a timeout, verify an event, render a backend status page or repair missed deliveries.
Payment lifecycle
Always treat the API status as authoritative. Do not infer completion from a browser redirect or from the customer saying they paid.
| Status | Meaning | Merchant action |
|---|---|---|
| created | Invoice and address allocated; no funding detected yet. | Show hosted checkout. |
| pending | Waiting for a usable on-chain payment. | Keep the order open. |
| underpaid | Funds arrived below the merchant's tolerance. | Ask the payer to send the displayed remainder. |
| confirming | Sufficient value detected; waiting for confirmations. | Do not fulfil yet. |
| completed | Required value and confirmations reached. | Fulfil exactly once. |
| overpaid | More than expected was confirmed. | Fulfil and review the excess. |
| expired | No qualifying payment was detected before expiry. | Create a fresh payment. |
| failed | Address allocation or processing failed. | Log the error and create a new payment. |
| refunded | An operator recorded the payment as refunded. | Reconcile with your refund process. |
On-chain transfers are irreversible. The public API does not expose a refund endpoint. A refunded state is an operator record and must be reconciled with the actual outbound refund transaction.
Hosted checkout
Each API payment includes a responsive checkout_url. It displays the merchant, requested presentment amount, locked USD equivalent when relevant, exact crypto amount, deposit address, QR code, network warning, countdown and live confirmation progress.
The customer sees the same crypto_amount returned by the API for the invoice window.
The payer does not create a CryptoPayIn account or share credentials.
The page polls the payment safely and moves from waiting to confirming to paid.
An HTTPS redirect_url is offered after success; it is not proof of payment.
Keep fulfilment on your backend. Browser navigation can be abandoned, repeated or forged; only a verified webhook or an authenticated GET proves the payment state.
Webhooks
Add up to 10 public HTTPS endpoints in Dashboard -> Developers. Each endpoint receives its own whsec_... signing secret, shown once. It can listen account-wide or be tied to a specific active API key; key-scoped endpoints receive only payments created with that key.
Verify before parsing
CryptoPayIn signs the exact raw request body using the endpoint secret. Version 1 signs timestamp + "." + raw_body. Reject stale timestamps before accepting the event.
import crypto from "node:crypto";
const timestamp = req.headers["x-cpi-timestamp"];
const signature = req.headers["x-cpi-signature"];
const rawBody = req.rawBody; // Buffer captured before JSON parsing
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error("stale webhook");
}
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.CPI_WEBHOOK_SECRET)
.update(Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]))
.digest("hex");
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!valid) throw new Error("invalid webhook signature");
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_CPI_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_CPI_SIGNATURE'] ?? '';
if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) {
http_response_code(400); exit('stale webhook');
}
$expected = 'sha256=' . hash_hmac(
'sha256', $timestamp . '.' . $rawBody, getenv('CPI_WEBHOOK_SECRET')
);
if (!hash_equals($expected, $received)) {
http_response_code(401); exit('invalid signature');
}
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
import hashlib, hmac, os, time
raw_body = request.get_data() # bytes, before JSON decoding
timestamp = request.headers.get("X-CPI-Timestamp", "")
received = request.headers.get("X-CPI-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
raise ValueError("stale webhook")
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(
os.environ["CPI_WEBHOOK_SECRET"].encode(), signed, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, received):
raise ValueError("invalid signature")Delivery headers
| Header | Example | Purpose |
|---|---|---|
| Content-Type | application/json | UTF-8 JSON body. |
| X-CPI-Timestamp | 1784293200 | Unix seconds included in the signed message. |
| X-CPI-Signature | sha256=... | Hex HMAC-SHA256. |
| X-CPI-Signature-Version | v1 | Signing scheme version. |
| X-CPI-Event-Id | evt_a12b... | Stable logical event ID; identical across retries. |
| X-CPI-Delivery-Id | 1842 | Stable endpoint delivery record ID. |
Payment payload
{
"event_id": "evt_a12b34c56d78e90f12345678",
"event": "payment.completed",
"id": "P-9F27C1E4KD",
"status": "completed",
"order_ref": "order_1042",
"amount": 49.99,
"amount_decimal": "49.99",
"amount_minor": 4999,
"currency_minor_units": 2,
"currency": "USD",
"amount_usd": 49.99,
"amount_usd_cents": 4999,
"fx_rate_usd": "1.000000000000",
"fx_source": "fixed:USD",
"fx_observed_at": "2026-07-17T13:00:00Z",
"fx_discrepancy_bps": 0,
"exchange_rate": "3600.00000000",
"exchange_rate_currency": "USD",
"exchange_rate_source": "median:cb,cg,cl",
"exchange_rate_source_count": 3,
"exchange_rate_observed_at": "2026-07-17T13:00:00Z",
"exchange_rate_discrepancy_bps": 12,
"asset": "ETH",
"network": "mainnet",
"crypto_amount": "0.01388612",
"crypto_received": "0.01388612",
"txid": "0x9d81...75af",
"confirmations": 12,
"confirmations_required": 12,
"deposit_address": "0x71b8c3d4700000000000000000000000000084e2",
"sent_at": "2026-07-17T13:12:42Z"
}Retries and endpoint safety
A delivery succeeds on HTTP 200-299. Do expensive work asynchronously and answer quickly.
The exact body and event ID are retained; failures retry after roughly 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours.
3xx responses are not followed. Register the final HTTPS URL directly.
Private, loopback, link-local and reserved IPs are blocked; every DNS answer is validated and the connection is pinned.
Deliveries are at least once. Make your handler idempotent by recording event_id with a unique constraint before fulfilment. Retrieve the API object when reconciling an unexpected event.
Event reference
payment.completedExpected value confirmed.payment.overpaidMore than expected confirmed.payment.underpaidFunding below tolerance detected.payment.expiredUnpaid invoice window closed.payment.failedPayment setup or processing failed.payment.refundedOperator recorded a refund.payout.sentWithdrawal broadcast on-chain.payout.confirmedWithdrawal reached confirmations.payout.failedWithdrawal could not complete.webhook.testManual connectivity test.Payout event shape
{
"event_id": "evt_b98c76d54e32a10f87654321",
"event": "payout.sent",
"id": "W-8J2K7M4RQP",
"status": "sent",
"asset": "ETH",
"network": "mainnet",
"amount": "0.25",
"fee": "0.00081768",
"address": "0x84f2...9bc1",
"txid": "0xa1c4...07ee",
"confirmations": 0,
"note": "weekly treasury",
"sent_at": "2026-07-17T14:08:31Z"
}Errors and rate limits
Errors always use one JSON envelope. Branch on error.type; the human message may be improved without a version change. Quote error.request_id or the matching X-Request-Id response header when contacting support.
{
"error": {
"type": "ambiguous_asset",
"message": "specify network (e.g. USDT.ERC20 or USDT.TRC20)",
"request_id": "b942e21f8dca4b06b8672eb9"
}
}| HTTP | Typical types | Meaning |
|---|---|---|
| 400 | invalid_request, unknown_parameter | Malformed JSON, query or idempotency header. |
| 401 | unauthorized | Missing, invalid or inactive credential/account. |
| 404 | not_found | Unknown endpoint or payment outside this merchant. |
| 405 | method_not_allowed | Use the method shown in the Allow header. |
| 409 | idempotency_conflict | Key reused with different JSON. |
| 413 | request_too_large | JSON body exceeds 64 KiB. |
| 415 | unsupported_media_type | POST body is not declared as application/json. |
| 422 | invalid_amount, invalid_currency, below_minimum, invalid_redirect, unsupported_asset | Well-formed request failed deterministic validation. |
| 429 | rate_limited | Wait for Retry-After. |
| 500 | server_error | Unexpected failure; retry safely with the same idempotency key. |
| 503 | maintenance, temporarily_unavailable, asset_unavailable, no_rate, price_stale, fiat_rate_unavailable, fiat_rate_stale, derive_failed | Transient platform, node, price or address-allocation failure. No stale conversion is substituted. |
Current limits
| Scope | Limit | Window |
|---|---|---|
| Nginx per-IP safety ceiling | 10 requests/second, burst 30 | Continuous |
| Unauthenticated IP ceiling | 300 requests | 60 seconds |
| POST /v1/payments | 120 requests per API key | 60 seconds |
| GET endpoints | 240 requests per API key | 60 seconds |
Successful application-limited responses expose X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Retry 429, 500 and 503 with exponential backoff and jitter. For POST, always reuse the original Idempotency-Key and identical JSON.
Integration security
Load them at runtime; never log the full value or commit it to source control.
A browser or mobile client cannot safely hold a merchant secret.
Check timestamp freshness and use constant-time signature comparison before JSON parsing.
Record processed events/orders transactionally so retries never ship twice.
Retrieve the payment when an event is unexpected or your local state disagrees.
Create a replacement key, deploy it, verify traffic, then delete the old key.
Account access is controlled by a non-recoverable 16-digit merchant key, optionally protected by TOTP. Store both merchant access and API secrets with the same care as wallet credentials.
Go-live checklist
Do not reuse a developer's personal copy across services.
Use GET /v1/assets and GET /v1/currencies; render only entries that are present and available: true.
Save the signing secret once; verify the timestamp and signature, then deduplicate the stable event ID.
Exercise a duplicate retry and confirm only one payment ID exists.
Your order state should remain safe through stale fiat rates, unavailable assets, delayed confirmations and every non-happy path.
Compare your orders with API payment states, webhook logs and the merchant ledger.
Ready to integrate?
Create an account in seconds, generate a key and keep this reference beside your code.
