Webhooks
From API call to payment status: CryptoLike posts a signed JSON event to your endpoint whenever a payment, deposit or withdrawal changes state. This page is the contract — request format, signature, retries, and how to process events exactly once.
1. Set up an endpoint
In the cabinet (Webhooks) or through the API, add an endpoint with:
- URL — an absolute
https://URL on a public host. Plainhttp://, private or loopback addresses and URLs with credentials are refused when you save them and again when we connect. - Events — the types you want, or
*for all (see §5). - Secret — generated for you (64 hex characters), or your own (16–256 printable ASCII characters). The secret is shown once on creation and on rotation; keep it on your server only. Rotate it from the cabinet at any time.
You can add several endpoints (for example one per environment). Each has its own secret and subscription. Disabling an endpoint stops new deliveries and marks the queued ones as exhausted; the journal is kept.
Per-payment URL. A payment created with webhook_url also receives its invoice.* events at that URL. Deliveries to it are signed with the secret of your oldest enabled endpoint; if you have none, a secret is generated for that payment and shown in the endpoint list (kind invoice). To keep verification simple, create at least one endpoint before using webhook_url.
2. The request
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: CryptoLike-Webhooks/1.0
X-Event-Id: 6f1c2c1e-3b2a-4d5e-8f90-1234567890ab
X-Event-Type: invoice.paid
X-Timestamp: 1700000000
X-Signature: sha256=788e60a2141669b4c7616c1c616fefb8ec38df0948fba19c6f4e48c616e46c21
{"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"}}}| Field | Meaning |
|---|---|
event_id |
Unique id of the event. The same event is never given a different id, however many times it is delivered. |
type |
Event type (§5). Also in X-Event-Type. |
created_at |
When the event happened (RFC 3339, UTC) — not when it was sent. |
data |
The object the event is about: invoice, address + deposit, or withdrawal. Amounts are decimal strings, never floats. |
The example above is formatted for reading. The body you actually receive is canonical JSON as stored on our side: keys may come in a different order (data before event_id, for instance) and whitespace may differ. Do not rely on key order or on re-serialising the body — parse it as JSON, and verify the signature over the raw bytes exactly as received (§3). The bytes are the same on every delivery of an event.
Respond with any 2xx status within 10 seconds. Anything else — another status, a redirect, a timeout, a connection error — counts as a failed attempt and is retried (§4). Keep the handler fast: store the event and acknowledge, process afterwards.
3. Verify the signature
Every request is signed with your endpoint secret:
X-Signature = "sha256=" + hex( HMAC-SHA256( secret, X-Timestamp + "." + raw_body ) )Compute the same value from the raw request body (bytes as received — do not re-serialise the JSON) and the X-Timestamp header, and compare it to X-Signature with a constant-time comparison. Reject the request if they differ, and reject timestamps older than a few minutes to stop replays of a captured request.
Test vector — every example below must print true for it:
| 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 |
Node.js
const crypto = require("crypto");
function verify(secret, timestamp, rawBody, signature) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody) // Buffer of the raw body
.digest("hex");
const a = Buffer.from(expected), b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: keep the raw body — app.post("/hook", express.raw({ type: "application/json" }), handler)
app.post("/hook", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.header("X-Timestamp");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
if (!verify(process.env.WEBHOOK_SECRET, ts, req.body, req.header("X-Signature"))) return res.sendStatus(401);
const event = JSON.parse(req.body);
// store event.event_id; skip if already seen (§4)
res.sendStatus(200);
});Python
import hmac, hashlib, time
def verify(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Flask
@app.post("/hook")
def hook():
ts = request.headers["X-Timestamp"]
if abs(time.time() - int(ts)) > 300:
abort(400)
if not verify(SECRET, ts, request.get_data(), request.headers["X-Signature"]):
abort(401)
event = request.get_json()
# store event["event_id"]; skip if already seen
return "", 200Go
func verify(secret string, timestamp string, rawBody []byte, signature string) bool {
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 handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
ts := r.Header.Get("X-Timestamp")
if sec, err := strconv.ParseInt(ts, 10, 64); err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
w.WriteHeader(http.StatusBadRequest)
return
}
if !verify(os.Getenv("WEBHOOK_SECRET"), ts, body, r.Header.Get("X-Signature")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// decode body, store event_id, skip duplicates
w.WriteHeader(http.StatusOK)
}4. Retries and idempotency
Delivery is at least once. A delivery that does not get a 2xx is retried after 1 min, 5 min, 15 min, 1 h, 6 h, 24 h (7 attempts in total, about 31 h). After that it is marked exhausted; you can resend it from the cabinet at any time (Webhooks → Deliveries → Retry), and you can resend a delivered event too if you lost it.
Because of retries — and because a delivery can be recorded as failed after your server has in fact processed it — you may receive the same event more than once, with the same event_id, the same body and a fresh X-Timestamp/X-Signature. Make your handler idempotent:
- Verify the signature.
- Look
event_idup in your store. Seen → respond 2xx and stop. - Store
event_id, apply the event, respond 2xx.
Events of one payment are created in the order they happened, but deliveries are independent: a retried invoice.pending may arrive after invoice.paid. Use created_at (or the status in data) rather than arrival order, and treat data as the full current state of the object at created_at.
The Deliveries journal in the cabinet shows, for every delivery: the event, the endpoint, attempts made, the last response code and up to 2 KB of the response body, the request body as sent, and the next attempt time.
5. Event types
Subscribe to exact types or *.
| Type | When | data |
|---|---|---|
invoice.created |
A payment was created. | invoice |
invoice.pending |
The first transfer to the payment address was detected in the network. | invoice, details |
invoice.paid |
The payment is confirmed and credited to your balance. Also sent again when a further transfer to the same address raises overpaid_amount. |
invoice, details |
invoice.underpaid |
A confirmed transfer covers less than the amount due (outside the tolerance). remaining_amount tells how much is missing; a top-up to the same address completes the payment. |
invoice, details |
invoice.expired |
The payment timed out before it was paid. | invoice |
invoice.cancelled |
You cancelled the payment. | invoice |
invoice.late_payment |
A transfer was confirmed after the payment was closed (expired, cancelled or final underpaid). The funds are credited to your balance; the payment status does not change — decide whether to fulfil or refund. | invoice, details |
invoice.reverted |
A confirmation you were told about was rolled back by the network (reorganisation); paid_amount decreased and the status was recomputed. Stop fulfilment until a new invoice.paid arrives. |
invoice, details |
deposit.pending |
A transfer to one of your static addresses was detected. | address, deposit |
deposit.confirmed |
The transfer is confirmed and credited; deposit.fee and deposit.net_amount are known. |
address, deposit |
deposit.reverted |
The transfer was rolled back by the network. | address, deposit |
deposit.on_hold |
The transfer is held for compliance review. | address, deposit |
withdrawal.pending |
A payout was created and its full amount (total_debit) moved from available to hold. |
withdrawal |
withdrawal.review |
The payout waits for a manual check (limit, unusual pattern, compliance, signing policy). review_reason is the generic reason; up to 24 h. |
withdrawal |
withdrawal.awaiting_approval |
The payout waits for a second signature by another owner/admin of your account (dual approval). | withdrawal |
withdrawal.processing |
The payout is being signed and sent; it can no longer be cancelled. | withdrawal |
withdrawal.broadcast |
The transaction is in the network (txid); confirmations grows until the coin's requirement is met. |
withdrawal |
withdrawal.completed |
Confirmed: amount left to to_address, platform_fee and network_fee_charged were charged, the hold is released. |
withdrawal |
withdrawal.failed |
The network/node refused or dropped the transaction; the full hold is back in available. failure_reason says why (generic). |
withdrawal |
withdrawal.rejected |
Refused on review; the hold is back in available. |
withdrawal |
withdrawal.cancelled |
You cancelled it before processing; the hold is back in available. |
withdrawal |
Test event. From the cabinet (Webhooks → endpoint → Send test event) you can send a ping to one endpoint at any time: {"type":"ping","data":{"endpoint_id":"…","message":"…"}}, signed with that endpoint's secret and retried like any event. Respond with 2xx and ignore it in your business logic. ping cannot be subscribed to; it goes only to the endpoint you asked from.
invoice fields: id, status, order_id, currency, amount, coin_id, address, amount_crypto, payer_amount, paid_amount, remaining_amount, overpaid_amount, amount_fiat, fiat_currency, rate_locked, rate_locked_at, late_payment, fee_paid_by, ttl_expires_at, created_at. details names the transfer (chain_transaction_id, amounts) behind the transition.
address fields: id, address, coin_id, coin, network, customer_id, label, status, total_received, tx_count, last_deposit_at, created_at. deposit fields: chain_transaction_id, txid, output_index, amount, fee, net_amount, status (pending | confirmed | reverted), confirmations, required_confirmations, block_height, confirmed_reversed.
withdrawal fields: id, status, coin_id, amount (sent on-chain), platform_fee, network_fee_estimated (reserved), network_fee_charged (once completed), total_debit (= the hold: amount + platform fee + reserved network fee), to_address, payout_id, comment, txid, confirmations, review_reason (limit | pattern | manual | compliance | signer_limit), failure_reason, address_warning, batch_id, created_at, updated_at, completed_at. Only the four TZ §6.3 types (processing, completed, failed, rejected) are needed to track a payout to its end; the others are progress notifications.
Statuses in webhooks are the same statuses you see in the cabinet and the API.
6. Checklist
- [ ] Endpoint is
https://on a public host and answers 2xx within 10 s. - [ ] Signature verified from the raw body; timestamp checked against your clock.
- [ ]
event_idstored; duplicates acknowledged without side effects. - [ ] Handler tolerates out-of-order arrival;
datais treated as the current state atcreated_at. - [ ] Secret stored server-side only; rotated if it may have leaked.