Skip to content

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. Plain http://, 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

text
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:

text
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

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

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 "", 200

Go

go
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:

  1. Verify the signature.
  2. Look event_id up in your store. Seen → respond 2xx and stop.
  3. 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_id stored; duplicates acknowledged without side effects.
  • [ ] Handler tolerates out-of-order arrival; data is treated as the current state at created_at.
  • [ ] Secret stored server-side only; rotated if it may have leaked.