Source: https://cryptolike.net/docs/webhooks

# 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 enabled endpoint at the same URL, if there is one; otherwise with the secret of your oldest enabled endpoint. So `webhook_url` needs at least one enabled endpoint: without one the payment is refused (`400 validation_error`, `details.field = "webhook_url"`). If you disable or delete your last endpoint later, the payment's URL gets no more deliveries until you enable or add one (deliveries already queued are not sent). The secret is chosen when each delivery is sent, retries included: after you rotate an endpoint's secret, every later delivery to the payment's URL is signed with the new one — the old secret signs nothing from that moment.

## 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 (more, including negative cases
your verifier must reject, in [`vectors.json`](https://cryptolike.net/docs/sdk/vectors.json), generated from the platform's signer):

| | |
|---|---|
| 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` |
| `static_address.archived` | A static address was archived: `reason` `manual` (by you — cabinet, API, bot) or `auto` (no incoming transfers for the platform's auto-archive period, after an `archive_warning`). An archived address is still checked for incoming transfers regularly. | `address`, `reason` |
| `static_address.restored` | The address is active again: `reason` `manual` (by you) or `deposit` — an incoming transfer reached the archived address (`deposit`: `chain_transaction_id`, `txid`, `output_index`, `amount`, `block_height`; it is credited as usual with its own `deposit.*` events). `conflict_with` — the customer also has another active address for the currency: both stay active until you archive one. | `address`, `reason`, `deposit`, `conflict_with` |
| `static_address.archive_warning` | The address has had no incoming transfers since `inactive_since` and will be archived automatically after `archive_after` (an incoming transfer keeps it active). | `address`, `reason`, `archive_after`, `inactive_since` |

**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`, `ticker` and `network` (the unit of the crypto amounts, once the coin is known), `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`, `metadata` (your pairs from creation; absent when none). Fields that are not known yet are absent. `details` names the transfer (`chain_transaction_id`, `txid`, amounts) behind the transition. Shop plugins map these events to order statuses as in [integration guides](https://cryptolike.net/integrations).

The `data` of the three `static_address.*` events is described field by field in [OpenAPI spec](https://api.cryptolike.net/docs/openapi.yaml)
(`StaticAddressArchivedData`, `StaticAddressRestoredData`, `StaticAddressArchiveWarningData`); the
SDKs decode it into typed objects ([SDKs](https://cryptolike.net/docs/sdk), «Static-address archive»). An automatic archive, a
restore by a deposit and the archive warning are also sent to the Telegram bot, if you connected it.

`address` fields: `id`, `address`, `coin_id`, `coin`, `network`, `customer_id`, `label`, `status`, `total_received`, `tx_count`, `last_deposit_at`, `created_at`, and once archived/restored `archived_at`, `archive_reason`, `restored_at`, `restore_reason`, `restore_conflict_with`. `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` (English, for logs), `failure_code` (`reverted` | `compliance` | `network_rejected` | `funds_unavailable` | `dust` | `address_rejected` | `signing_policy` | `network_error` | `not_confirmed` — translate this one), `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.
