Integration guide

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: true proves payment.
https://crypteo.io/api/public/v1

Authentication

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

FieldTypeDescription
amountnumber, requiredUSD price (flexible) or token amount (fixed). Must be greater than 0. expectedAmount is accepted as an alias.
chainstringNetwork for a fixed payment, e.g. eth, sol, btc. Requires token.
tokenstringToken on that network, e.g. usdc, usdt, eth. Requires chain.
externalIdstring, ≤ 200 charsYour order or invoice ID, echoed back everywhere.
webhookUrlstring (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.
customerEmailstring (email)Buyer's email, shown to you in the dashboard.
metadataobjectYour own key/value data, stored with the payment.

Response — payment

FieldTypeDescription
idstringPayment ID. Store it with your order.
checkoutUrlstringHosted checkout. Redirect the buyer here.
amountnumberUSD (flexible) or token amount (fixed), as sent.
chain, tokenstring | nullNull for flexible payments until the buyer picks.
walletAddressstring | nullReceiving address (fixed payments only).
xrpDestinationTag, solMemoStringstring | nullPer-payment identifier the buyer attaches on XRP / Solana.
expiresAtstringWhen 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

FieldTypeDescription
idstringPayment ID.
statusstringOne of the statuses below.
paidbooleantrue when paid in full (confirmed or overpaid). Fulfil on this.
externalIdstring | nullYour order ID from creation.
chain, tokenstring | nullNetwork and token, once chosen.
expectedAmountnumberAmount due, in token units once a network is chosen.
receivedAmountnumber | nullAmount received on-chain, in token units.
txHashstring | nullOn-chain transaction ID, when resolved.
walletAddressstring | nullAddress the buyer was asked to pay.
expiresAt, createdAt, updatedAtstringISO 8601 timestamps.
isTestbooleanCreated 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.

statuspaidMeaningWhat to do
pendingfalseWaiting for the buyer to pay.Nothing yet.
confirmingfalsePayment seen on-chain, waiting for confirmations.Show 'processing'. Don't fulfil yet.
confirmedtruePaid in full and confirmed.Fulfil the order.
overpaidtruePaid more than asked.Fulfil the order. Refund the difference if you choose.
underpaidfalsePart of the amount arrived.Wait for a top-up, or settle receivedAmount manually.
expiredfalseNothing arrived before expiry.Cancel or release the order.
expired_receivedfalseFunds 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" }'

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)

PaymentDestination
Created with a webhookUrl (API), or through a link with a custom URLThat URL
Created with an API key that has an endpointThat endpoint
Created through a payment link that has an endpointThat endpoint
Anything else (no URL, key or link endpoint)Your default endpoint
Its endpoint is paused or deletedNowhere: it is never re-routed to another endpoint

Request headers

HeaderDescription
Content-Typeapplication/json
X-Webhook-IdThe event's eventId, for deduplication.
X-SignatureHex 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

FieldTypeDescription
eventstringpayment. + the new status, e.g. payment.confirmed. 'Send test' sends webhook.test with payment: null.
eventIdstring{paymentId}:{status}:{receivedAmount}. Identical across retries and resends; a new amount or status is a new id.
paymentobjectid, externalId, status, paid, chain, token, expectedAmount, receivedAmount, txHash, createdAt, updatedAt.
timestampstringWhen 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 on status === "confirmed": overpaid is 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.

StatusMeaningRetry?
400Invalid 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
401Missing, invalid or revoked API key.No
403Not 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
404Payment not found, or it belongs to another account or mode.No
409The payment can no longer be changed (e.g. a deposit was already seen).No — read it again
429Rate limit exceeded.Yes, after Retry-After
500Unexpected server error.Yes, with backoff
503Chain 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 fulfilment

XRP tags & Solana memos

XRP and Solana payments get a per-payment identifier, xrpDestinationTag or solMemoString, shown to the buyer at checkout. It lets one wallet receive many payments at once and still match each one exactly.

A payment sent without it (common from exchange withdrawals) is still detected and matched by amount. Use a self-custodied XRP address you control: an exchange deposit address that requires its own fixed tag is not supported, because buyers are shown the per-payment tag. XRP wallets must be classic r… addresses.

Supported 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.

chaintokens
etheth, usdc, usdt
solsol, usdc, usdt
btcbtc
xrpxrp
bscbnb, usdc, usdt (BEP-20)
polygonpol, usdc, usdt
trontrx, usdt (TRC-20)
dogedoge
zeczec (transparent addresses only)
adaada

Stuck on an integration?

Test the whole flow with a test_ key in the playground, or tell us what you're building.

Contact us