Skip to content

CryptoLike API — Authentication

Every call to the merchant API (/v1/*, except the public catalogue and the payment page) is signed with your API key. Signing takes three headers and one HMAC. Once your client signs correctly, it signs correctly for every endpoint.

Sign on your server, never in a browser or a mobile app. The secret signs as you: whoever can read your page's JavaScript could create payments, read balances and change your webhooks. The browser only ever needs an invoice id — see the widget contract (Checkout widget §1). The reference client (@cryptolike/api-client, createMerchantClient) refuses to run in a browser.

1. Keys

Create keys in the cabinet: API keys → Create key. You get:

Field Example Notes
Key ID ck_live_4f9k2m7p1q8r3s6t0u5v9w2x Public. Sent in X-API-Key. ck_test_… for test keys.
Secret kZ1Yv7pQ2xL9mN4tR8wE3uH6jB5cF0aD1gS2hK7lP9o Shown once. 43 characters. Store it in your secret manager.

Each key has permissions (read, create_invoice, create_address, withdraw), a request limit (600 per minute by default, adjustable per key) and an optional IP whitelist. The cabinet shows the last 4 characters of the secret (…lP9o) so you can tell keys apart; nobody, including us, can display the full secret again. Lost it? Rotate the key (§8).

withdraw needs an owner or admin. An owner or admin grants withdraw with their own two-factor code. A team member with the Developer role only requests it: the key works at once for its other permissions, and its payouts answer 403 withdraw_pending_approval until an owner or admin — not the person who asked — approves the request in the cabinet (API keys) with a two-factor code. The approval changes nothing in the key's secret. Without a decision the request expires after 72 hours; a rejected or expired request leaves the key working without withdraw. A Developer who rotates or re-enables a payout key asks again. When the requester or the approver leaves the team or changes role, the key loses withdraw.

The approval covers the IP whitelist too. A request keeps the key's IP whitelist as it was when the request was filed, and the approver sees that list. If the key's whitelist is different when they approve, the approval is refused and the request is cancelled. A Developer who changes the IP whitelist of a key that has withdraw or a pending request confirms with a two-factor code; the key loses withdraw (or the pending request is cancelled), and a new request for the new whitelist waits for an owner or admin — payouts with the key stop until then. An owner or admin decides a pending request before changing that key's whitelist.

The approval covers the secret too. An approval is for the person who holds the key's secret — the team member it was shown to: the creator, then whoever rotated it last (secret_holder and secret_generation in the key). A Developer who does not hold the current secret cannot ask for withdraw on it — re-enabling a disabled payout key, changing its IP whitelist or adding withdraw is refused with 409 (details.reason = withdraw_requires_rotation) and nothing changes. They rotate the key first: the new secret is theirs, and the request is for that secret only. When the person behind an approval leaves the team or changes role while their secret still works — the current one, or the previous one during the rotation grace — the key loses withdraw (current secret), or their previous secret stops working at once (previous secret).

A key that needs a new secret. If the platform could not confirm who holds a key's current secret (a database correction of 2026-10-08 for keys rotated by two people at about the same time), the key shows rotation_required: true: its payout permission was removed, a pending request for it was cancelled, and nobody — an owner or admin included — can add or ask for withdraw (409, details.reason = withdraw_requires_rotation) until the key's secret is rotated. The rotation ends it; the secret it replaces never pays out during its grace. Owners and admins get an inbox notice for each such key. The key's other permissions keep working.

2. Headers

Header Value
X-API-Key your key ID
X-Timestamp current Unix time in seconds (1758000000)
X-Signature lower-case hex HMAC-SHA256 of the canonical string (§3), keyed with the secret
Content-Type application/json for requests with a body
Idempotency-Key optional, POST only — see §7
X-API-Version optional, v1 — pins the contract version you were written for (API changelog); every response carries it back

3. Canonical string

Join four lines with \n (LF, no trailing newline):

text
<timestamp>
<METHOD>
<path[?query]>
<hex(sha256(body))>
  • timestamp — exactly the X-Timestamp value.
  • METHOD — upper-case HTTP method (GET, POST).
  • path[?query] — the request target as you send it: path plus query string if any, no scheme or host. /v1/invoices, /v1/balances?limit=10. Sign the query the way you encode it.
  • hex(sha256(body)) — lower-case hex SHA-256 of the raw request body bytes. For an empty body (every GET) it is e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

Then X-Signature = hex(HMAC-SHA256(key = secret bytes, message = canonical string)). The key is the secret string exactly as shown — do not base64-decode it.

Reference vector

Use it to check your implementation before the first real call.

text
secret     kZ1Yv7pQ2xL9mN4tR8wE3uH6jB5cF0aD1gS2hK7lP9o
timestamp  1758000000
method     POST
path       /v1/invoices
body       {"amount":"10.50","currency":"USD","order_id":"A-1001"}

sha256(body)      50bf8f3c2622754522fb1343f2597a4843d08e4649255eefbdce1f800d3b457f
canonical string  "1758000000\nPOST\n/v1/invoices\n50bf8f3c2622754522fb1343f2597a4843d08e4649255eefbdce1f800d3b457f"
X-Signature       7878d965e6eb95b80df12e34e9466d40a8e12aa612653f72760f5dc5c06323a1

GET /v1/balances?limit=10 with the same secret and timestamp, empty body → 34b530cc1f7d5760f991e23d1f8c499bb166356d7da7c17f7984016199be9310.

More vectors — query strings, unicode bodies, PUT/DELETE — with the canonical string and the body hash spelled out: vectors.json (api_request), generated from the platform's own signer; wire them into your SDK's test suite.

4. Examples

curl

bash
KEY_ID=ck_live_…; SECRET=…
TS=$(date +%s); METHOD=POST; PATH_Q=/v1/invoices
BODY='{"amount":"10.50","currency":"USD","order_id":"A-1001"}'
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" "$METHOD" "$PATH_Q" "$BODY_HASH" \
      | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -sS "https://api.cryptolike.net$PATH_Q" -X "$METHOD" \
  -H "X-API-Key: $KEY_ID" -H "X-Timestamp: $TS" -H "X-Signature: $SIG" \
  -H "Content-Type: application/json" -H "Idempotency-Key: order-A-1001" \
  --data "$BODY"

Node.js

js
import crypto from "node:crypto";

export async function call(method, pathWithQuery, body = "") {
  const ts = Math.floor(Date.now() / 1000).toString();
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const canonical = [ts, method.toUpperCase(), pathWithQuery, bodyHash].join("\n");
  const sig = crypto.createHmac("sha256", process.env.CL_SECRET).update(canonical).digest("hex");
  return fetch("https://api.cryptolike.net" + pathWithQuery, {
    method,
    headers: {
      "X-API-Key": process.env.CL_KEY_ID,
      "X-Timestamp": ts,
      "X-Signature": sig,
      "Content-Type": "application/json",
    },
    body: body || undefined,
  });
}

// await call("POST", "/v1/invoices", JSON.stringify({ amount: "10.50", currency: "USD", order_id: "A-1001" }));

Python

python
import hashlib, hmac, os, time, requests

def call(method: str, path_with_query: str, body: bytes = b"") -> requests.Response:
    ts = str(int(time.time()))
    body_hash = hashlib.sha256(body).hexdigest()
    canonical = "\n".join([ts, method.upper(), path_with_query, body_hash])
    sig = hmac.new(os.environ["CL_SECRET"].encode(), canonical.encode(), hashlib.sha256).hexdigest()
    return requests.request(
        method, "https://api.cryptolike.net" + path_with_query, data=body or None,
        headers={"X-API-Key": os.environ["CL_KEY_ID"], "X-Timestamp": ts,
                 "X-Signature": sig, "Content-Type": "application/json"},
    )

# call("GET", "/v1/balances")

Go

go
func sign(secret string, ts int64, method, pathQuery string, body []byte) string {
	sum := sha256.Sum256(body)
	canonical := strconv.FormatInt(ts, 10) + "\n" + strings.ToUpper(method) + "\n" + pathQuery + "\n" + hex.EncodeToString(sum[:])
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(canonical))
	return hex.EncodeToString(mac.Sum(nil))
}

ts := time.Now().Unix()
req, _ := http.NewRequest("POST", "https://api.cryptolike.net/v1/invoices", bytes.NewReader(body))
req.Header.Set("X-API-Key", keyID)
req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10))
req.Header.Set("X-Signature", sign(secret, ts, "POST", "/v1/invoices", body))
req.Header.Set("Content-Type", "application/json")

5. Errors

All errors share one shape: {"error":{"code":"…","message":"…","details":{…}}}. Branch on code; message is for humans and may change.

HTTP code Meaning What to do
401 auth.missing_header One of the three headers is absent. Send all three.
401 auth.invalid_key The key is disabled or revoked. Only a request signed with the key's valid secret is told this. Enable the key or create a new one in the cabinet.
401 auth.invalid_timestamp X-Timestamp is not an integer. Send Unix seconds.
401 auth.expired_timestamp |server time − timestamp| > 300 s. details.server_time is our clock. Sync your clock (NTP); milliseconds are a common cause.
401 auth.invalid_signature The request is not signed with a valid secret of this key: HMAC mismatch, or an unknown key ID. Without a valid signature the answer is the same whatever the key's state — unknown, disabled, revoked, account blocked or frozen, IP outside the whitelist — so a key ID alone tells nothing about the key. Check the key ID and re-check the canonical string against the vector: method case, query string, body bytes, secret as shown.
401 auth.test_key A ck_test_… key sent to an environment that runs on mainnets. Decided by the key prefix alone; nothing is created, read or moved. details.docs_url links §9. Use a ck_live_… key here; keep test keys for the test environment (§9).
401 auth.replay This exact signature was already accepted. Each request needs a fresh timestamp; do not resend a captured request. Two identical requests (same method, target and body) within the same second share a signature — wait for the next second and sign again; the official SDKs (SDKs) do this automatically.
403 auth.ip_not_allowed Client IP is outside the key's whitelist (told only to a correctly signed request). details.ip shows the address we saw. Add the IP/CIDR in the cabinet or use a key without a whitelist.
403 auth.permission_denied Key lacks the scope in details.required. With details.reason = previous_secret: a payout signed with the previous secret of a rotated key (§8). Grant the permission or use another key; for previous_secret, sign with the new secret.
403 withdraw_pending_approval Payouts only: the key's withdraw was requested and waits for an owner or admin (§1) — also after a Developer changed the IP whitelist of a payout key. Nothing was created or held; the Idempotency-Key is still free. Ask an owner or admin to approve the request in the cabinet (API keys), then retry. Do not retry automatically.
429 rate_limited Key over its request limit, or — with details.scope — a sensitive operation over its limit, or the client IP blocked after repeated refused authentications. Retry-After in seconds. Back off for Retry-After; see §6.
503 unavailable The request could not be checked right now (authentication or limit store). Nothing was done. Retry later with the same request.
409 idempotency.in_progress The same Idempotency-Key is being processed now. Wait and repeat the same request.
422 idempotency.mismatch The same Idempotency-Key was used with a different request. Use one key per logical operation.
413 payload_too_large Body over 1 MiB. —
400 unsupported_api_version X-API-Version names a version this server does not serve; details.supported lists what it does. Send v1 or drop the header.

Every 401 also carries WWW-Authenticate: CryptoLike-HMAC-SHA256.

6. Request limits

Each key has a token bucket: X-RateLimit-Limit requests per minute (default 600), refilled continuously; X-RateLimit-Remaining tells you how many are left right now. Over the limit: 429 rate_limited with Retry-After. Unauthenticated requests (wrong signature, expired timestamp) never consume your key's tokens. A per-key limit can be set in the cabinet.

Sensitive operations — payouts (POST /v1/withdrawals) here, and in the cabinet API-key changes, payouts, whitelist changes and 2FA changes — have limits of their own, counted per API key, per merchant, per person and per client IP. Running into one answers 429 rate_limited with Retry-After and details.scope (key, merchant, account or ip — which of them hit the limit); repeating it while blocked makes the next block longer. Mass payouts belong in one batch, not in a burst of single requests; if your volume is legitimately higher, ask support to raise the limit for your account.

Refused authentications (wrong key, signature or timestamp, a replay, an IP outside the key's whitelist) are counted per client IP. An address that keeps failing is blocked for a while: every request from it — even a correctly signed one — answers 429 rate_limited with details.scope = "ip" and Retry-After until the block ends. Fix the signing (§10) before retrying in a loop.

7. Idempotency (POST)

Send Idempotency-Key: <your unique value> (1–128 printable ASCII characters, e.g. your order ID) with any POST. If the request is repeated — a timeout, a retry, a duplicate job — you get the original response back (Idempotent-Replayed: true) and nothing is created twice. Keys are remembered for 24 hours per API key. The repeat must be the same request: same method, path and body; a different body with the same key is 422 idempotency.mismatch. Success responses (2xx) are stored. A 4xx releases the key — nothing was created — so a retry after you fix the key's permissions, switch a coin on or correct the request runs again and reflects the current state. A 5xx (and a timeout) is stored and replayed like a success (Idempotent-Replayed: true) for the same 24 hours: we cannot always tell whether the request was applied before the error, and running a payout twice is worse than an answer you have to check. After a replayed 5xx, look the object up (GET /v1/withdrawals?payout_id=…, your order_id) before you send the request again with a new key. The exception is a 503 unavailable that refuses before anything is written (exchange rate, network fee estimate, address screening or the coin temporarily unavailable): it releases the key, and a retry with the same key after a backoff runs again.

Withdrawals (POST /v1/withdrawals) keep the key for good. Besides the stored response, the key is written together with the withdrawal and its balance hold, in one database transaction. A retry with the same key therefore returns the same withdrawal (200/201, same id) even after the stored response is gone, and never creates a second one. The binding is per merchant — any API key of the merchant — and does not expire: the same key with a different coin_id, amount, to_address, payout_id or comment is 422 idempotency.mismatch, also after 24 hours. Values are compared, not bytes ("0.10" and "0.1" are the same amount). Use one new key per withdrawal; payout_id gives the same protection by your own identifier.

When both are sent, the key decides first: a key already spent on another withdrawal is 422 idempotency.mismatch even if the body's payout_id names an existing withdrawal — you never get another withdrawal's answer under your key. A new key on a repeat of an existing payout_id (same coin, amount, address) returns that withdrawal and is bound to it from then on, so reusing that key for another payout is 422. Other parameters under an existing payout_id stay 409. Parallel copies of one request (same key or same payout_id) all get the one withdrawal back, even when the balance covers a single payout — never insufficient_funds for the copies.

8. Rotation and revocation

  • Rotate (cabinet → key → Rotate) issues a new secret and shows it once. The previous secret keeps working for 10 minutes so you can deploy the new one without downtime; previous_secret_valid_until in the key tells you when it stops. Rotating again within that window drops the oldest secret — at most two secrets verify at any time.
  • Payouts move to the new secret at once. During the grace the previous secret works for every permission except withdraw: a payout signed with it is 403 auth.permission_denied (details.reason = previous_secret). The one exception is an owner or admin rotating a key whose secret they already held and whose withdraw they granted themselves — then payouts on the previous secret keep working through the grace. A payout permission approved for a Developer is for one secret only (§1): after a rotation the new secret needs a new approval.
  • A previous secret ends early when the person who held it leaves the team or changes role during the grace: from then on any request signed with it is 401 auth.invalid_signature (the secret is gone), not 403 previous_secret. The new secret keeps working.
  • Disable stops the key until you enable it again (401 auth.invalid_key meanwhile).
  • Revoke is final: the key stops immediately and cannot be re-enabled. Create a new one.

Every creation, change, rotation and revocation is written to the team security log with the person who did it.

API keys are not cabinet sessions: a password reset, "sign out everywhere" or a 2FA change ends every cabinet session of the person, but keys keep working until you rotate, disable or revoke them — after a suspected account compromise, rotate the keys too. In the cabinet, a password or 2FA change that was checked before such a reset landed is refused with 401 and changes nothing (sign in again).

9. Test keys

ck_test_… keys sign exactly like live keys. A test key never moves real money. Each environment runs either mainnets or test networks, never a mix, and the cabinet shows which one (networks in GET /v1/account/api-keys: mainnet or testnet).

Environment ck_live_… key ck_test_… key
mainnet (production) Works; moves real funds. Not issued: creating one is 400 validation_error on mode. Refused on every request — reads too — with 401 auth.test_key, before the key is even looked up: no invoice, address, payout or webhook endpoint is created.
testnet (test environment) Works; moves test coins only. Works; moves test coins only.

Both refusals name this section in details.docs_url (https://cryptolike.net/docs/auth#9-test-keys) and in the message.

A key belongs to the environment that issued it: the test environment and production do not share accounts or keys, so a key from one is 401 auth.invalid_signature in the other whatever its prefix. On the test environment the prefix is a label for you — both modes see the same objects of the account and move only test coins; the key prefix never decides which network a payment uses, the environment does.

Build your integration with a test key against the test environment (CryptoLike support gives you access and its API URL), then switch to a live key and the production API URL. There is no test mode inside production: a separate sandbox with its own objects on the same servers would put test and real payments next to each other, so production simply refuses test keys.

10. Checklist when a signature fails

  1. Timestamp in seconds, not milliseconds; clock synced.
  2. Method upper-case; path includes the query string exactly as sent.
  3. Body hash over the exact bytes you send (mind pretty-printing and trailing newlines).
  4. HMAC key is the secret string itself, not decoded.
  5. Signature is hex, 64 characters.
  6. The four parts are joined with \n and nothing after the last one.