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):
<timestamp>
<METHOD>
<path[?query]>
<hex(sha256(body))>timestamp— exactly theX-Timestampvalue.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 (everyGET) it ise3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
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.
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 7878d965e6eb95b80df12e34e9466d40a8e12aa612653f72760f5dc5c06323a1GET /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
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
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
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
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_untilin 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 is403 auth.permission_denied(details.reason = previous_secret). The one exception is an owner or admin rotating a key whose secret they already held and whosewithdrawthey 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), not403 previous_secret. The new secret keeps working. - Disable stops the key until you enable it again (
401 auth.invalid_keymeanwhile). - 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
- Timestamp in seconds, not milliseconds; clock synced.
- Method upper-case; path includes the query string exactly as sent.
- Body hash over the exact bytes you send (mind pretty-printing and trailing newlines).
- HMAC key is the secret string itself, not decoded.
- Signature is hex, 64 characters.
- The four parts are joined with
\nand nothing after the last one.