
Accept crypto in your app
Create a payment, send the buyer to the hosted checkout, and fulfil the order when a signed webhook says it's paid. Every payment settles straight to your own wallet.
Overview
Crypteo is a self-custodial crypto payment gateway: buyers pay straight into your own wallets, and we watch the chain and tell your server when a payment completes. We never hold funds or keys.
Every integration follows the same four steps. (1) Your server creates a payment with your API key and keeps its id next to your order. (2) You send the buyer to the returned checkoutUrl. (3) We notify your server with a signed webhook on every status change. You can also poll the payment. (4) You fulfil the order when the payment's paid field is true.
All requests and responses are JSON over HTTPS. Amounts are numbers, timestamps are ISO 8601 UTC strings, and chain/token values are lowercase strings.
- Call the API only from your server. Your API key must never reach a browser or mobile app.
- Never fulfil an order because the buyer came back to your site. Only a webhook or an API read with
paid: trueproves payment.
https://crypteo.io/api/public/v1Authentication
Create API keys in your dashboard under API Keys. Live keys start with crypt_ and create real payments. Test keys start with test_ and create sandbox payments that are never watched on-chain (see Test mode).
Send the key in an Authorization: Bearer header or an x-api-key header. A missing, revoked or unknown key returns 401 {"error":"Unauthorized"}. A key only sees payments of its own mode: a test key cannot read live payments, and a live key cannot read test ones.
We store only a SHA-256 hash of each key, so a key is shown once when you create it. Keep it in an environment variable and rotate it from the dashboard if it leaks.
curl "https://crypteo.io/api/public/v1/payments/PAYMENT_ID" \
-H "Authorization: Bearer $CRYPTEO_API_KEY"Create a payment
POST /payments opens a checkout session that expires one hour after creation. There are two kinds.
Flexible (recommended): omit chain and token. amount is then a USD price, and the buyer picks a network from your active wallets at checkout. The token amount is priced when they pick.
Fixed: pass chain and token together. amount is then in units of that token (0.01 with eth/eth means 0.01 ETH), and the buyer pays on that network only.
Set externalId to your own order ID: it comes back in every webhook and API read, so you can match events to orders without storing anything else. Webhooks for the payment go to the endpoint you chose for the API key that created it, or to your default endpoint (see Webhooks). Pass webhookUrl to send this one payment's webhooks somewhere else instead.
Request body
| Field | Type | Description |
|---|---|---|
| amount | number, required | USD price (flexible) or token amount (fixed). Must be greater than 0. expectedAmount is accepted as an alias. |
| chain | string | Network for a fixed payment, e.g. eth, sol, btc. Requires token. |
| token | string | Token on that network, e.g. usdc, usdt, eth. Requires chain. |
| externalId | string, ≤ 200 chars | Your order or invoice ID, echoed back everywhere. |
| webhookUrl | string (URL) | Optional. Public http(s) server route that accepts POST. This payment's webhooks go here instead of to the API key's endpoint or your default endpoint. |
| customerEmail | string (email) | Buyer's email, shown to you in the dashboard. |
| metadata | object | Your own key/value data, stored with the payment. |
Response — payment
| Field | Type | Description |
|---|---|---|
| id | string | Payment ID. Store it with your order. |
| checkoutUrl | string | Hosted checkout. Redirect the buyer here. |
| amount | number | USD (flexible) or token amount (fixed), as sent. |
| chain, token | string | null | Null for flexible payments until the buyer picks. |
| walletAddress | string | null | Receiving address (fixed payments only). |
| xrpDestinationTag, solMemoString | string | null | Per-payment identifier the buyer attaches on XRP / Solana. |
| expiresAt | string | When the checkout closes (creation + 1 hour). |
curl -X POST "https://crypteo.io/api/public/v1/payments" \
-H "Authorization: Bearer $CRYPTEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 25,
"externalId": "order_1042",
"customerEmail": "[email protected]",
"webhookUrl": "https://api.your-shop.com/webhooks/crypteo"
}'Get a payment
GET /payments/{id} returns the payment's current state. Use it to confirm a webhook before acting on it, or as a fallback if your webhook endpoint was down.
expectedAmount and receivedAmount are in units of the payment's token once a network is chosen. For a flexible payment that hasn't been picked yet, expectedAmount is still the USD price and availableWallets lists the networks the buyer can choose.
If you poll instead of using webhooks, poll every 15–30 seconds while the payment is open. Stop as soon as it reaches a final status (see Payment statuses), and never poll a payment forever: a session lasts one hour, plus up to 24 hours for an underpaid top-up.
Response — payment
| Field | Type | Description |
|---|---|---|
| id | string | Payment ID. |
| status | string | One of the statuses below. |
| paid | boolean | true when paid in full (confirmed or overpaid). Fulfil on this. |
| externalId | string | null | Your order ID from creation. |
| chain, token | string | null | Network and token, once chosen. |
| expectedAmount | number | Amount due, in token units once a network is chosen. |
| receivedAmount | number | null | Amount received on-chain, in token units. |
| txHash | string | null | On-chain transaction ID, when resolved. |
| walletAddress | string | null | Address the buyer was asked to pay. |
| expiresAt, createdAt, updatedAt | string | ISO 8601 timestamps. |
| isTest | boolean | Created with a test key. |
curl "https://crypteo.io/api/public/v1/payments/cmv2let0k0088iuglbuwggt6o" \
-H "Authorization: Bearer $CRYPTEO_API_KEY"Payment statuses
Fulfil an order when paid is true. That covers both confirmed and overpaid: an overpayment is still a full payment. An integration that only checks for confirmed leaves overpaid orders unfulfilled.
underpaid means only part of the amount arrived. The buyer can top up within 24 hours after the checkout expires; if the total reaches the amount, the payment moves to confirmed and you get a new event.
expired and expired_received are final for automatic detection, so stop polling them. A late payment (expired_received) is not credited automatically, but our team may still credit an expired payment after review. That change reaches you as a normal webhook, so keep your handler able to move an expired order to paid.
| status | paid | Meaning | What to do |
|---|---|---|---|
| pending | false | Waiting for the buyer to pay. | Nothing yet. |
| confirming | false | Payment seen on-chain, waiting for confirmations. | Show 'processing'. Don't fulfil yet. |
| confirmed | true | Paid in full and confirmed. | Fulfil the order. |
| overpaid | true | Paid more than asked. | Fulfil the order. Refund the difference if you choose. |
| underpaid | false | Part of the amount arrived. | Wait for a top-up, or settle receivedAmount manually. |
| expired | false | Nothing arrived before expiry. | Cancel or release the order. |
| expired_received | false | Funds arrived after expiry. | Review: refund, or credit it yourself. |
function orderStateFor(payment) {
if (payment.paid) return "paid"; // confirmed | overpaid
switch (payment.status) {
case "pending":
case "confirming":
return "awaiting_payment";
case "underpaid":
return "partially_paid"; // a top-up may still complete it
case "expired":
return "cancelled";
case "expired_received":
return "needs_review"; // money arrived late
default:
return "awaiting_payment"; // unknown future status: don't fulfil
}
}Select chain/token
Only needed if you build your own checkout instead of using checkoutUrl. PATCH /payments/{id} locks a flexible payment to one network: we price the USD amount in that token and return the address and amount the buyer must send.
A payment can be re-pointed only while it is pending, nothing has been received, and it was created without a chain. Otherwise the request is refused with 400 or 409. Show the buyer walletAddress and amount exactly, plus xrpDestinationTag or solMemoString when present.
curl -X PATCH "https://crypteo.io/api/public/v1/payments/cmv2let0k0088iuglbuwggt6o" \
-H "Authorization: Bearer $CRYPTEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "chain": "sol", "token": "usdc" }'Payment links
A payment link is a reusable page: each visit opens a new payment session for amount USD. Use them for invoices, donations or social posts. They need a live key, because every payment through a link is real.
Optionally pin a chain and token (together), set expiresAt (ISO date) and a paymentLimit (how many payments it accepts). A pinned pair needs an active wallet with a valid address for that network, or the request is refused with 400. A link never takes an address of its own: payments always go to your saved wallet.
Each link sends its payments' webhooks to one place: pass webhookEndpointId (one of your endpoints, e.g. the site that sells it) or webhookUrl (a custom URL), not both. With neither, they go to your default endpoint. In the dashboard, the link form offers the same choice.
curl -X POST "https://crypteo.io/api/public/v1/payment-links" \
-H "Authorization: Bearer $CRYPTEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Premium Subscription",
"description": "Monthly access",
"amount": 29.99,
"expiresAt": "2026-12-31T23:59:59Z",
"paymentLimit": 100,
"webhookEndpointId": "cmv9whe1b2c3d4e5f6g7h8i9"
}'Webhooks
We POST a JSON event every time a payment's status changes, including confirming, underpaid top-ups, and expiry.
Each payment is sent to exactly one URL, never to several. Add one endpoint per site in Settings → Webhooks (up to 10), and mark one as the default. The destination is, in order: (1) the payment's own webhookUrl, if you passed one when creating it through the API or set a custom URL on its payment link; (2) the endpoint chosen for the API key that created it, or for the payment link it came through; (3) otherwise your default endpoint. Running several sites on one account? Create one API key per site and point each key at that site's endpoint (API Keys page): every site then hears only about its own payments. A payment keeps the endpoint its key or link named when it was created, so changing a key's endpoint applies to the payments it creates afterwards. Payments on your default go to whichever endpoint is the default when each delivery is made.
Each URL must be a server route that accepts POST and answers with any 2xx status within 10 seconds. A URL that your frontend or a static site serves answers 405 Not Allowed, and every delivery fails. Use 'Send test' next to an endpoint in Settings to check it before a real payment depends on it. Redirects are not followed and count as failures, so use the final https URL.
A failed delivery is retried after 1 minute, 5 minutes, 15 minutes, 1 hour and 6 hours. Each retry carries the payment's state at that moment and goes to the destination as it is then, so fixing an endpoint's URL also fixes its pending retries. A new status change starts a new event with its own retries. A paused endpoint receives nothing, and a payment routed to a paused or deleted endpoint is never sent to another endpoint instead. Settings and your dashboard show which endpoints are failing, and you can resend the latest event from the payment's page.
Where a payment's webhooks go (first match wins)
| Payment | Destination |
|---|---|
Created with a webhookUrl (API), or through a link with a custom URL | That URL |
| Created with an API key that has an endpoint | That endpoint |
| Created through a payment link that has an endpoint | That endpoint |
| Anything else (no URL, key or link endpoint) | Your default endpoint |
| Its endpoint is paused or deleted | Nowhere: it is never re-routed to another endpoint |
Request headers
| Header | Description |
|---|---|
| Content-Type | application/json |
| X-Webhook-Id | The event's eventId, for deduplication. |
| X-Signature | Hex HMAC-SHA256 of the raw body, keyed with your signing secret (one secret for all of your endpoints). Sent only when a secret is set in Settings → Webhooks. |
Payload
| Field | Type | Description |
|---|---|---|
| event | string | payment. + the new status, e.g. payment.confirmed. 'Send test' sends webhook.test with payment: null. |
| eventId | string | {paymentId}:{status}:{receivedAmount}. Identical across retries and resends; a new amount or status is a new id. |
| payment | object | id, externalId, status, paid, chain, token, expectedAmount, receivedAmount, txHash, createdAt, updatedAt. |
| timestamp | string | When this delivery was built. |
{
"event": "payment.confirmed",
"eventId": "cmv2let0k0088iuglbuwggt6o:confirmed:25",
"payment": {
"id": "cmv2let0k0088iuglbuwggt6o",
"externalId": "order_1042",
"status": "confirmed",
"paid": true,
"chain": "eth",
"token": "usdc",
"expectedAmount": 25,
"receivedAmount": 25,
"txHash": "0x5f1c…9b",
"createdAt": "2026-10-15T12:00:00.000Z",
"updatedAt": "2026-10-15T12:04:10.000Z"
},
"timestamp": "2026-10-15T12:04:10.120Z"
}Verify signatures
Set a signing secret in Settings → Webhooks and verify every request. All of your endpoints are signed with the same secret. Anyone can POST to your URL, so a request without a valid signature must be rejected with 401 and must change nothing.
Compute HMAC-SHA256 over the raw request body, exactly as received, using your secret, and compare its hex digest to the X-Signature header with a constant-time comparison. Hash the raw bytes, not a re-serialised JSON object: parsing and re-encoding changes the bytes and the signature never matches.
import crypto from "node:crypto";
import express from "express";
const app = express();
// express.raw keeps the exact bytes we signed. Register this route
// BEFORE any app.use(express.json()) so the body isn't parsed first.
app.post(
"/webhooks/crypteo",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.get("X-Signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.CRYPTEO_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.status(401).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
await handleEvent(event); // see "Handle events"
res.sendStatus(200);
},
);Handle events
Webhooks are delivered at least once: a retry or a manual resend can bring the same event again, and a slow response can make us retry an event you already processed. Record each eventId you handle and skip ones you've seen.
Check event first: act on payment.* events and acknowledge anything else (such as webhook.test) with 2xx. If an event names an order you don't have (for example a payment you created from another system without a webhookUrl), acknowledge it and do nothing.
Find the order by payment.id (stored at creation) or payment.externalId. Act on payment.paid, and never move an order backwards: once it's paid it stays paid, whatever a later or out-of-order event says. For high-value orders, re-read GET /payments/{id} and act on that response.
Answer 2xx quickly. If fulfilment is slow (sending emails, provisioning), record the event, respond, and do the work in a background job, or the 10-second timeout fails the delivery and we retry it.
- Fulfil on
paid: true, not only onstatus === "confirmed":overpaidis paid too. - Use a server route for every webhook URL, not a page of your website: a frontend route answers 405 and you never hear about payments. 'Send test' in Settings catches this at once.
- Respond 2xx even for events you ignore (e.g.
payment.confirming), or they are retried. - Compare amounts in the payment's token (
expectedAmount/receivedAmount). Your USD price is what you sent at creation.
// Idempotent: safe to run twice for the same event.
async function handleEvent(event) {
// 0. Only payment events carry a payment (Send test sends webhook.test).
if (!event.event.startsWith("payment.")) return;
const { eventId, payment } = event;
// 1. Deduplicate (unique index on processed_events.event_id).
const fresh = await db.processedEvents.insertIfAbsent(eventId);
if (!fresh) return;
// 2. Find the order.
const order =
(await db.orders.findByPaymentId(payment.id)) ??
(await db.orders.findById(payment.externalId));
if (!order) return; // another site's order: acknowledge and ignore
// 3. Never go backwards once paid.
if (order.status === "paid") return;
if (payment.paid) {
await db.orders.update(order.id, {
status: "paid",
txHash: payment.txHash,
paidAmount: payment.receivedAmount,
paidToken: payment.token,
});
await jobs.enqueue("fulfil-order", { orderId: order.id }); // slow work off the request
return;
}
switch (payment.status) {
case "underpaid":
await db.orders.update(order.id, { status: "partially_paid", paidAmount: payment.receivedAmount });
break;
case "expired":
await db.orders.update(order.id, { status: "cancelled" });
break;
case "expired_received":
await db.orders.update(order.id, { status: "needs_review" });
break;
// pending / confirming: nothing to do
}
}Errors & rate limits
Errors use HTTP status codes and a JSON body { "error": "message" }. Validation errors also carry a details object naming the failing field. Error messages are meant for logs, not for buyers.
Requests are limited per minute: 120 per live key, 30 per test key, and 60 per IP address for unauthenticated calls. Over the limit you get 429 with a Retry-After header in seconds. A 503 means a blockchain or price source was briefly unreachable and nothing was created; it carries Retry-After too. Retry 429, 503 and 500 with backoff. Don't retry 4xx errors without changing the request.
| Status | Meaning | Retry? |
|---|---|---|
| 400 | Invalid request: missing/invalid field, unsupported chain/token pair, no active wallet for the pair, an invalid wallet address configured, a webhookUrl that isn't a public http(s) address, or a webhookEndpointId that isn't one of your endpoints. Field errors name the field in field. | No — fix the request or your wallet settings |
| 401 | Missing, invalid or revoked API key. | No |
| 403 | Not allowed with this key (e.g. payment links with a test key), or the account isn't taking payments: suspended, or paused for overdue platform fees until the invoice on your Billing page is paid. A paused account's checkouts show buyers that the merchant needs to act. | No — pay or contact support |
| 404 | Payment not found, or it belongs to another account or mode. | No |
| 409 | The payment can no longer be changed (e.g. a deposit was already seen). | No — read it again |
| 429 | Rate limit exceeded. | Yes, after Retry-After |
| 500 | Unexpected server error. | Yes, with backoff |
| 503 | Chain or price source unreachable; nothing was created. | Yes, after Retry-After |
HTTP/1.1 400 Bad Request
{
"error": "Invalid data format",
"details": { "amount": { "_errors": ["Amount must be a positive number"] } }
}
HTTP/1.1 503 Service Unavailable
Retry-After: 30
{ "error": "Unable to reach the blockchain network right now, so this payment could not be opened. No funds have been requested. Please retry shortly." }Test mode
Use a test_ key to build your integration without real funds. Test payments are created with isTest: true and are never watched on-chain. Their checkout shows a 'Simulate Payment' button that marks the payment confirmed, which you can then read with GET /payments/{id}.
Test payments don't send webhooks. To check an endpoint is reachable and verifies signatures, use 'Send test' next to it in Settings → Webhooks (it sends a signed webhook.test event). To exercise your payment handling, sign a sample payment event with your secret and POST it to your own endpoint, as below. Then use a small live payment to confirm the whole path end to end.
// node send-test-webhook.mjs http://localhost:3000/webhooks/crypteo
import crypto from "node:crypto";
const url = process.argv[2];
const body = JSON.stringify({
event: "payment.confirmed",
eventId: "test_pay_1:confirmed:25",
payment: {
id: "test_pay_1", externalId: "order_1042", status: "confirmed", paid: true,
chain: "eth", token: "usdc", expectedAmount: 25, receivedAmount: 25,
txHash: "0xtest", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
},
timestamp: new Date().toISOString(),
});
const signature = crypto
.createHmac("sha256", process.env.CRYPTEO_WEBHOOK_SECRET)
.update(body)
.digest("hex");
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Webhook-Id": "test_pay_1:confirmed:25", "X-Signature": signature },
body,
});
console.log(res.status, await res.text()); // expect 2xx; then again → still 2xx, no double fulfilmentSupported chains
Pass these lowercase chain/token pairs. A buyer is only offered pairs for which you have an active, valid wallet in Wallets. Zcash is watched on transparent (t1…/t3…) addresses only.
| chain | tokens |
|---|---|
| eth | eth, usdc, usdt |
| sol | sol, usdc, usdt |
| btc | btc |
| xrp | xrp |
| bsc | bnb, usdc, usdt (BEP-20) |
| polygon | pol, usdc, usdt |
| tron | trx, usdt (TRC-20) |
| doge | doge |
| zec | zec (transparent addresses only) |
| ada | ada |
Stuck on an integration?
Test the whole flow with a test_ key in the playground, or tell us what you're building.