CryptoLike signs every webhook with HMAC-SHA256: X-Signature is "sha256=" plus the hex HMAC of X-Timestamp + "." + raw body, keyed with your endpoint secret. To verify a delivery, recompute that value over the raw bytes, compare it in constant time, reject timestamps more than 300 seconds away from your clock in either direction, then deduplicate by event_id and answer 2xx within 10 seconds.
A webhook tells your server that a payment, deposit or payout changed status; the webhook signature proves the message came from CryptoLike and was not altered. The full contract is in the webhook documentation. This guide turns it into working code for TypeScript, Python, Go and PHP; each snippet was checked against the reference test vector and the negative vectors published with the official SDKs.
Before you start
A CryptoLike webhook integration needs four things in place before the first delivery can be verified: a CryptoLike account, a webhook endpoint on a public https:// URL, the endpoint secret stored on your server, and a server that can read the raw request body. Test keys (ck_test_) and test networks let you try it before going live.
- Endpoint: Developers → Webhooks → Add endpoint, or
POST /v1/webhooks/endpoints; events such asinvoice.paid, or*for all. - Secret: generated (64 hex characters) or your own (16–256 printable ASCII characters without spaces, codes 0x21–0x7e), shown once.
- Clock: synced with NTP — the timestamp check allows ±5 minutes.
1. Store the endpoint secret on the server
CryptoLike creates one secret per webhook endpoint and shows it once. Copy it into your server's environment or secret manager, for example as CRYPTOLIKE_WEBHOOK_SECRET, and never into browser code, a mobile app or a repository. If the secret may have leaked, rotate it in the cabinet and deploy the new value.
Each endpoint has its own secret, so staging and production never share one. An empty secret — a missing environment variable — must reject every request; all four snippets in step 4 do.
2. Read the raw request body and two headers
CryptoLike computes the signature over the request body exactly as sent, so your handler must read the raw bytes before any JSON parser touches them. The body is canonical JSON as stored on the platform: key order and whitespace may differ from the documentation examples, and re-serialising a parsed object produces different bytes and a failed check.
You need two headers: X-Timestamp (Unix seconds) and X-Signature (sha256= plus 64 lower-case hex characters). Raw body by framework: Express — express.raw({ type: "application/json" }); Flask — request.get_data(); Go — io.ReadAll(r.Body); PHP — file_get_contents('php://input'), not $_POST.
3. Reject stale timestamps
CryptoLike puts the send time in X-Timestamp and signs it together with the body, so a captured request cannot be replayed later with a new timestamp. Reject a delivery when the difference between your clock and X-Timestamp exceeds 300 seconds — the default tolerance of all four official SDKs. A rejected delivery is not lost: retries carry a fresh timestamp and signature.
4. Recompute the HMAC and compare in constant time
CryptoLike builds the signature as "sha256=" + hex(HMAC-SHA256(secret, X-Timestamp + "." + raw_body)). Compute the same string on your side and compare it with X-Signature using a constant-time function — timingSafeEqual, hmac.compare_digest, hmac.Equal or hash_equals — so that response timing does not reveal how many characters matched. Compare the whole string, including the sha256= prefix.
TypeScript (Node.js, Express)
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const TOLERANCE_S = 300; // reject deliveries signed more than 5 minutes away from our clock
export function verifyCryptoLikeWebhook(
rawBody: Buffer,
timestamp: string | undefined,
signature: string | undefined,
secret: string,
nowS = Math.floor(Date.now() / 1000),
): boolean {
if (!secret || !timestamp || !signature || !/^[0-9]+$/.test(timestamp)) return false;
if (Math.abs(nowS - Number(timestamp)) > TOLERANCE_S) return false;
const expected = Buffer.from(
"sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"),
);
const received = Buffer.from(signature);
return expected.length === received.length && timingSafeEqual(expected, received);
}
const app = express();
app.post("/hooks/cryptolike", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyCryptoLikeWebhook(req.body, req.header("X-Timestamp"), req.header("X-Signature"),
process.env.CRYPTOLIKE_WEBHOOK_SECRET ?? "");
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
if (!alreadySeen(event.event_id)) saveForProcessing(event); // your storage
res.sendStatus(200);
});Python (Flask)
import hashlib, hmac, json, os, time
from typing import Optional
from flask import Flask, abort, request
TOLERANCE_S = 300 # reject deliveries signed more than 5 minutes away from our clock
def verify_cryptolike_webhook(raw_body: bytes, timestamp: Optional[str], signature: Optional[str],
secret: str, now: Optional[float] = None) -> bool:
if not secret or not timestamp or not signature:
return False
if not (timestamp.isascii() and timestamp.isdecimal()):
return False
if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_S:
return False
mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest(("sha256=" + mac.hexdigest()).encode(), signature.encode())
app = Flask(__name__)
@app.post("/hooks/cryptolike")
def cryptolike_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify_cryptolike_webhook(raw, request.headers.get("X-Timestamp"),
request.headers.get("X-Signature"),
os.environ.get("CRYPTOLIKE_WEBHOOK_SECRET", "")):
abort(401)
event = json.loads(raw)
if not already_seen(event["event_id"]):
save_for_processing(event) # your storage
return "", 200Go (net/http)
const tolerance = 300 * time.Second // reject deliveries signed more than 5 minutes away from our clock
func VerifyCryptoLikeWebhook(rawBody []byte, timestamp, signature, secret string, now time.Time) bool {
sec, err := strconv.ParseUint(timestamp, 10, 63)
if err != nil || secret == "" || signature == "" {
return false
}
if d := now.Sub(time.Unix(int64(sec), 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func cryptoLikeWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !VerifyCryptoLikeWebhook(body, r.Header.Get("X-Timestamp"), r.Header.Get("X-Signature"),
os.Getenv("CRYPTOLIKE_WEBHOOK_SECRET"), time.Now()) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event struct {
EventID string `json:"event_id"`
Type string `json:"type"`
}
if err := json.Unmarshal(body, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !alreadySeen(event.EventID) {
saveForProcessing(event.EventID, body) // your storage
}
w.WriteHeader(http.StatusOK)
}Imports: crypto/hmac, crypto/sha256, encoding/hex, encoding/json, io, net/http, os, strconv, time.
PHP (7.4 and later)
<?php
const CRYPTOLIKE_TOLERANCE_S = 300; // reject deliveries signed more than 5 minutes away from our clock
function verify_cryptolike_webhook(string $rawBody, ?string $timestamp, ?string $signature, string $secret, ?int $now = null): bool
{
if ($secret === '' || $timestamp === null || $signature === null || !ctype_digit($timestamp)) {
return false;
}
if (abs(($now ?? time()) - (int) $timestamp) > CRYPTOLIKE_TOLERANCE_S) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$raw = file_get_contents('php://input'); // raw body, not $_POST
$ok = verify_cryptolike_webhook($raw, $_SERVER['HTTP_X_TIMESTAMP'] ?? null,
$_SERVER['HTTP_X_SIGNATURE'] ?? null, getenv('CRYPTOLIKE_WEBHOOK_SECRET') ?: '');
if (!$ok) { http_response_code(401); exit; }
$event = json_decode($raw, true);
if (!already_seen($event['event_id'])) { save_for_processing($event); } // your storage
http_response_code(200);5. Deduplicate by event_id and answer 2xx within 10 seconds
CryptoLike delivers webhooks at least once, so the same event can arrive more than once — with the same event_id and body and a fresh signature. Look up event_id first: seen means answer 2xx and stop; new means store it, apply the event, answer 2xx. Keep the handler fast: store and acknowledge within 10 seconds, then process.
event_id plays the same role for incoming events that an idempotency key plays for your API requests: one logical operation, applied once. Both sides are explained in idempotency keys in payment APIs.
A delivery without a 2xx answer is retried after 1 min, 5 min, 15 min, 1 h, 6 h and 24 h — 7 attempts over about 31 hours — and then marked exhausted. Resend it from the cabinet: Developers → Webhooks → Deliveries → Retry. Deliveries are independent, so a retried invoice.pending can arrive after invoice.paid: order by created_at or read the status in data, which is the full state of the object at created_at.
6. Test with the reference vector and a test event
CryptoLike publishes a test vector that every verifier must accept, so you can check your code before the first real delivery. Call your function with the values in the table and the timestamp check disabled or the clock pinned to 1700000000: it must return true. Change one byte of the body and it must return false.
| Input | Value |
|---|---|
| secret | whsec_9f2c1e4b7a6d5c3b2a1f0e9d8c7b6a5f |
| X-Timestamp | 1700000000 |
| body | {"event_id":"6f1c2c1e-3b2a-4d5e-8f90-1234567890ab","type":"invoice.paid","created_at":"2023-11-14T22:13:20Z","data":{"invoice":{"id":"0e8c9a4b-1111-4222-8333-444455556666","status":"paid"}}} |
| X-Signature | sha256=788e60a2141669b4c7616c1c616fefb8ec38df0948fba19c6f4e48c616e46c21 |
Then run a live check: Developers → Webhooks → endpoint → Send test event delivers a signed ping to that endpoint only. Answer 2xx and ignore it in business logic.
Use the SDK helper instead
The official CryptoLike SDKs for TypeScript, Python, Go and PHP ship a ready verifier that implements exactly these checks: the sha256= prefix, a constant-time comparison, a 300-second timestamp tolerance by default, and a parsed event as the result. A failed check raises a verification error; answer 401. The SDKs are pre-release and installed from the repository — see the SDK documentation.
| Language | Call | Guide |
|---|---|---|
| TypeScript | verifyWebhook(req.headers, req.body, secret) |
TypeScript SDK |
| Python | verify_webhook(request.headers, request.get_data(), secret) |
Python SDK |
| Go | cryptolike.VerifyWebhook(r.Header, body, secret, nil) |
Go SDK |
| PHP | Webhook::verify(getallheaders(), file_get_contents('php://input'), $secret) |
PHP SDK |
Errors and fixes
When CryptoLike webhook verification fails, the cause is usually one of four integration mistakes: a parsed body instead of the raw one, the wrong secret, a clock out of sync, or a slow handler. The table maps what you see in your logs or in the cabinet's Deliveries journal to the cause and the fix.
| What you see | Cause | Fix |
|---|---|---|
| Signature never matches, test vector passes | Body re-serialised by a JSON middleware | Read the raw bytes before parsing (step 2). |
| Signature never matches, test vector fails | sha256= prefix dropped, upper-case hex, or a timestamp without the . separator |
Build the string exactly as in step 4. |
| Works in staging, fails in production | Secret of another endpoint | Each endpoint has its own secret; copy the production one. |
| Every delivery rejected as stale | Server clock drifted | Sync the clock with NTP; the window is ±300 s. |
| Deliveries retried although your code ran | Answer took more than 10 s or was not 2xx | Store, answer 2xx, process afterwards. |
| An order was fulfilled twice | No event_id check |
Store event_id and skip repeats (step 5). |
FAQ
These answers cover the questions developers ask most often when they verify CryptoLike webhook signatures: which secret signs which delivery, why the body must stay raw, what happens after a rejection, and how an invoice.paid event relates to the payment status in the API.
Which secret signs a delivery to a per-payment webhook_url?
A payment created with webhook_url gets its invoice.* events signed with the secret of your oldest enabled endpoint; there is no separate per-payment secret. So webhook_url needs an enabled endpoint: without one, POST /v1/invoices answers 400 validation_error with the field webhook_url. Add the endpoint in Developers → Webhooks first.
Why can I not verify the parsed JSON?
The signature covers bytes, not a data structure. Parsing and serialising again changes key order, whitespace and escapes, so the HMAC no longer matches. Verify the raw body, then parse it. The bytes are the same on every delivery of an event.
What happens after I answer 401?
The attempt counts as failed and is retried on the schedule of 1 min, 5 min, 15 min, 1 h, 6 h and 24 h. After the last attempt the delivery is exhausted; fix the cause and resend it from Developers → Webhooks → Deliveries → Retry.
Is invoice.paid enough to ship an order?
Yes: invoice.paid means the payment is confirmed and credited to your balance. Also handle invoice.reverted — a network reorganisation rolled a confirmation back — and stop fulfilment until a new invoice.paid arrives. The status model is in the API documentation.