ChainMore
ChainMore / Developers

From your checkout
to a confirmed payment.

Your server creates a payment. Your customer pays on our checkout page. We tell your server when the money has arrived. Five steps, all of them below.

Before you start

  • Account. You need a ChainMore account. Request access; we review every company and reply by email. You start in test mode.
  • Two addresses. The API is https://api.chainmore.io. The checkout page is https://checkout.chainmore.io.
  • API key. Create one in the dashboard under API keys. A key that starts with cm_test_ works in test mode, cm_live_ in live mode. It is shown once. Keep it on your server only, never in a browser or app.
  • Where your money goes. Add a payout destination in the dashboard under Payouts. Without one, the checkout has no payment method to offer.

Test mode uses real money for stablecoins. USDT and USDC payments in test mode run on the real networks. Test with small amounts and a wallet you control.

1Create a payment

POST/v1/checkout/links

When a customer wants to pay, your server sends this request. amount_minor is the amount in cents: 1000 means USD 10.00.

curl https://api.chainmore.io/v1/checkout/links \
  --request POST \
  --header "Authorization: Bearer ${CHAINMORE_API_KEY}" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: order-1048" \
  --data '{
    "amount_minor": 1000,
    "currency": "USD",
    "merchant_order_id": "order-1048",
    "return_url": "https://shop.example/thanks"
  }'
  • Idempotency-Key is required on every POST, PUT, PATCH and DELETE. Use one key per order. If a response gets lost, send the same request with the same key: you get the same payment back instead of a second one.
  • merchant_order_id is your own order number. It comes back in every webhook, so you can match the payment to the order. Allowed: letters, digits and . _ : -, up to 128 characters.
  • return_url is optional and must start with https://. After a successful payment the checkout shows a button back to this address.

The response contains checkout_link_id, checkout_token and available_payment_methods. Save the link ID with your order. The token comes only in the first response, not when you repeat the request. A link stays open for 24 hours.

2Send the customer to the checkout

Redirect the customer to this address, with the token URL-encoded:

https://checkout.chainmore.io/checkout/?token=<checkout_token>

The checkout does the rest: it shows the payment methods for this link and guides the customer through the wallet or card steps. Open it as a full page or in a new tab. It does not work inside an iframe on your site. Never put your API key in this URL.

3Receive the result

Add your endpoint in the dashboard under Webhooks. It must be a public https address. You get a signing secret that starts with whsec_; it is shown once. Endpoints and keys are managed in the dashboard only. An API key gets 401 wrong_auth_kind on those routes.

When a payment has arrived, we send a POST to your endpoint. The JSON body has event_type, event_id, merchant_order_id and the payment ID.

Ship the order on payment.settled. It comes for every way to pay: USDT, USDC on the same network or across networks, and card. If you sent no merchant_order_id, the field holds the checkout_link_id.

Check every message before you trust it:

  • X-ChainMore-Timestamp is the send time in RFC 3339 UTC, for example 2026-10-07T08:00:00Z. Reject it if it is more than 5 minutes away from your clock.
  • X-ChainMore-Signature is a lowercase hex HMAC-SHA256. The key is your whole whsec_… secret as text. The signed message is the timestamp, a dot, and the raw request body. Compare in constant time.
  • While you rotate the secret, we also send X-ChainMore-Signature-Previous, signed with the old secret, for 14 days.
// Node.js with Express. Read the body raw, not as parsed JSON.
import crypto from "node:crypto";

app.post("/chainmore/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("X-ChainMore-Timestamp") ?? "";
  const signature = req.get("X-ChainMore-Signature") ?? "";
  const expected = crypto
    .createHmac("sha256", process.env.CHAINMORE_WEBHOOK_SECRET) // the whole whsec_… value
    .update(timestamp + ".")
    .update(req.body)
    .digest("hex");
  const got = Buffer.from(signature);
  const want = Buffer.from(expected);
  const valid = got.length === want.length && crypto.timingSafeEqual(got, want);
  const fresh = Math.abs(Date.now() - Date.parse(timestamp)) < 5 * 60 * 1000;
  if (!valid || !fresh) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  if (event.test_event) return res.sendStatus(200); // "Send test event": nothing is paid
  if (event.event_type === "payment.settled") {
    markOrderPaid(event.merchant_order_id, event.event_id);
  }
  res.sendStatus(200);
});
  • Answer with a 2xx status within 10 seconds. Any other answer, 4xx included, counts as failed. We try again after 1 second, 5 seconds, 30 seconds, 5 minutes and 30 minutes. After that the delivery stays failed and you can send it again in the dashboard under Deliveries.
  • The same event can reach you more than once. Process each event_id once, and make "mark order paid" safe to run twice: a resend from the dashboard gets a new event_id.
  • Each event goes to one endpoint. If several endpoints match, the oldest one gets it.
  • "Send test event" in the dashboard sends a signed payment.settled with "test_event": true and made-up IDs. Answer 2xx and do not mark anything paid or ship anything for it.
  • Ship the order only after this message. A customer coming back to your site, or a transaction hash, does not prove the payment.

4Check the status yourself

GET/v1/checkout/links/{id}

You can also ask for the state of a link. bound means the customer chose a payment method. It does not mean the order is paid. Follow the payment_id in the response:

  • GET /v1/payments/{id}: paid when public_status is completed.

5Go live

When everything works in test mode, tell us by email. We switch your account to live mode. Then create a key that starts with cm_live_ and replace the test key on your server.

Errors and retries

Every error comes as application/problem+json with a stable code, a docs_url that explains it, and a correlation_id. Match on code, not on the text. Send us the correlation_id when you need help.

You seeMeaningFix
400 idempotency_requiredThe header is missing.Send Idempotency-Key, one value per order.
401 unauthorizedThe key is missing or wrong.Check Authorization: Bearer cm_test_….
401 wrong_auth_kindThis route is for the dashboard only.Manage keys and webhooks in the dashboard.
No payment method in the checkoutNo payout destination yet.Add one under Payouts.
Checkout page shows 404Wrong host.The checkout lives on checkout.chainmore.io, not on the API host.
Signature never matchesThe body was parsed before the check.Read the raw body and use the whole whsec_… value as key.
429 rate_limitedToo many requests.Wait a moment, then retry with the same Idempotency-Key.

All codes: chainmore.io/errors.

Gateway API

The complete reference.

Search endpoints and inspect request fields, responses and authentication. Endpoint availability depends on the deployment and your merchant configuration.

Raw OpenAPI file ↓

Loading the API reference… Download the specification.