Set up webhooks
Register an endpoint, verify the HMAC signature on every delivery, and replay anything you missed from the event log.
Create your signing secret, register a webhook, simulate a delivery against your handler, and recover anything you missed. Do this first, because the rest of the lifecycle is driven by webhooks.
Scopes webhook:write to create, simulate and delete, and webhook:read to read and replay
Prerequisites: An API key pair from the developer console
1. Create your signing secret
One secret for your whole tenant. Do this before registering any webhook, because anything delivered while no secret exists goes out unsigned. Creating a second secret destroys the first, so store this value before moving on: it is never retrievable again.
curl -X POST https://api.clc.solutions/v1/webhooks/secrets \
-H "X-CLC-Key-Id: $CLC_KEY_ID" \
-H "X-CLC-Timestamp: $CLC_TIMESTAMP" \
-H "X-CLC-Signature: $CLC_SIGNATURE" \
-H "Idempotency-Key: {uuid}"{
"secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
}2. Register a webhook
Omit event_types (or send it empty) to receive every event. The response is only the id.
curl -X POST https://api.clc.solutions/v1/webhooks \
-H "X-CLC-Key-Id: $CLC_KEY_ID" \
-H "X-CLC-Timestamp: $CLC_TIMESTAMP" \
-H "X-CLC-Signature: $CLC_SIGNATURE" \
-H "Idempotency-Key: {uuid}" \
-d '{
"url": "https://broker.example/clc-hooks",
"event_types": [
"facility.decisioned",
"margin_warning.raised"
],
"label": "prod margin ops"
}'{
"id": "whk_1a2b"
}3. Simulate a delivery (sandbox only)
Only on api-sandbox.clc.solutions. Simulating makes CLC emit an event that never happened, which is what you want while building a handler and never what you want against real borrowers, so the live stack does not serve this endpoint.
Sends a real signed request to your URL carrying the payload you supply, then reports what your endpoint answered. Nothing is written to the event log and no lending state changes, so run it as often as you need.
curl -X POST https://api-sandbox.clc.solutions/v1/webhooks/simulate \
-H "X-CLC-Key-Id: $CLC_KEY_ID" \
-H "X-CLC-Timestamp: $CLC_TIMESTAMP" \
-H "X-CLC-Signature: $CLC_SIGNATURE" \
-d '{
"webhook_id": "whk_1a2b",
"event_type": "margin_warning.raised",
"data": {
"facility_id": "fac_7a1b",
"tier": 1,
"cure_amount": {
"amount": "6000.00",
"asset": "USD"
}
}
}'{
"webhook_id": "whk_1a2b",
"event_type": "margin_warning.raised",
"response": {
"status": 200,
"body": "ok",
"error": null
}
}4. Verify the signature on every delivery
Every delivery carries an HMAC-SHA256 signature. Compute it yourself and compare before you trust the payload.
| Header | Contents |
|---|---|
X-CLC-Webhook-Signature | sha256=<hex> |
X-CLC-Webhook-Timestamp | unix seconds |
X-CLC-Event-Id | the event id, stable across retries. Deduplicate on it |
X-CLC-Delivery-Attempt | which attempt this is, counting from 1 |
The signed string is the timestamp, a dot, and the raw body. Sign the exact bytes you received: parsing the JSON and re-serializing it will change them and the signature will not match.
<timestamp>.<raw body>package clcwebhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"time"
)
const toleranceSeconds = 300
// Verify reports whether a delivery genuinely came from CLC. body must be the exact
// bytes received, read before any JSON decoding.
func Verify(secret, body []byte, h http.Header) bool {
timestamp := h.Get("X-CLC-Webhook-Timestamp")
received := h.Get("X-CLC-Webhook-Signature")
if timestamp == "" || received == "" {
return false
}
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || abs(time.Now().Unix()-sent) > toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
// Constant time. A plain == leaks the signature through timing.
return hmac.Equal([]byte(expected), []byte(received))
}
func abs(n int64) int64 {
if n < 0 {
return -n
}
return n
}import crypto from "node:crypto";
const SECRET = process.env.CLC_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// rawBody must be the exact bytes received, before any JSON parsing.
export function verify(rawBody, headers) {
const timestamp = headers["x-clc-webhook-timestamp"];
const received = headers["x-clc-webhook-signature"];
if (!timestamp || !received) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > TOLERANCE_SECONDS) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(`${timestamp}.${rawBody}`).digest("hex");
// Constant time. A plain === leaks the signature through timing.
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib
import hmac
import os
import time
SECRET = os.environ["CLC_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, headers) -> bool:
"""raw_body must be the exact bytes received, before any JSON parsing."""
timestamp = headers.get("X-CLC-Webhook-Timestamp")
received = headers.get("X-CLC-Webhook-Signature")
if not timestamp or not received:
return False
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
# Constant time. A plain == leaks the signature through timing.
return hmac.compare_digest(expected, received)The timestamp is what stops replays: without checking it, anyone who captures one delivery can resend it to you forever.
This is unrelated to request signing. You sign your requests to CLC with Ed25519 and CLC never holds your private key; CLC signs its deliveries to you with this shared secret. Opposite directions, different algorithms. See Authentication & Authorization.
5. Check delivery health
consecutive_failed_attempts resets to 0 on any 2xx from your endpoint. At 100 it flips state to disabled and CLC stops delivering. Events keep landing in the event log while it is disabled, so nothing is lost and you can replay them once you are back. There is no update endpoint, so delete the webhook and register it again to resume.
curl -X GET https://api.clc.solutions/v1/webhooks/whk_1a2b \
-H "X-CLC-Key-Id: $CLC_KEY_ID" \
-H "X-CLC-Timestamp: $CLC_TIMESTAMP" \
-H "X-CLC-Signature: $CLC_SIGNATURE"{
"id": "whk_1a2b",
"url": "https://broker.example/clc-hooks",
"event_types": [
"facility.decisioned",
"margin_warning.raised"
],
"label": "prod margin ops",
"consecutive_failed_attempts": 0,
"state": "active"
}6. Recover missed events
Scoped to one webhook, so you get only the types it subscribes to. Page with from, passing the next_cursor of the previous page. If you run several webhooks, page each one.
curl -X GET 'https://api.clc.solutions/v1/webhooks/whk_1a2b/events?from={cursor}' \
-H "X-CLC-Key-Id: $CLC_KEY_ID" \
-H "X-CLC-Timestamp: $CLC_TIMESTAMP" \
-H "X-CLC-Signature: $CLC_SIGNATURE"{
"items": [],
"next_cursor": "evt_c123"
}7. List everything you have registered
curl -X GET https://api.clc.solutions/v1/webhooks \
-H "X-CLC-Key-Id: $CLC_KEY_ID" \
-H "X-CLC-Timestamp: $CLC_TIMESTAMP" \
-H "X-CLC-Signature: $CLC_SIGNATURE"{
"items": [
{
"id": "whk_1a2b",
"url": "https://broker.example/clc-hooks",
"event_types": [
"facility.decisioned",
"margin_warning.raised"
],
"label": "prod margin ops"
}
]
}8. Delete a webhook
Deliveries stop immediately. Past events stay in the log, but without the webhook there is no cursor to replay them from, so fetch anything you still need first.
curl -X DELETE https://api.clc.solutions/v1/webhooks/whk_1a2b \
-H "X-CLC-Key-Id: $CLC_KEY_ID" \
-H "X-CLC-Timestamp: $CLC_TIMESTAMP" \
-H "X-CLC-Signature: $CLC_SIGNATURE"# HTTP 204 No ContentReference
The signing secret
One secret covers your whole tenant, not one per webhook. Create it before you register anything: deliveries sent while no secret exists go out unsigned, and nothing can authenticate them after the fact.
Creating a second secret destroys the first immediately, and there is no overlap window. Deploy the new value to your endpoint in the same window you create it, or you will reject your own deliveries in between.
Live and sandbox are separate stacks, so each has its own secret. A sandbox secret never validates a live delivery.
Delivery semantics
- At-least-once and unordered, so deduplicate on
X-CLC-Event-Id, and key your state onresource_version(ignore stale versions). The event id is the same on every retry and on every webhook it reaches, so a redelivery you already processed is easy to recognise. X-CLC-Delivery-Attemptcounts from 1 and resets per event, so a value above 1 means we already failed to reach you with this one. Branch on it if it helps: log louder, skip work you will discard, wake your own on-call. For how long it has been failing rather than how many times, compareoccurred_atin the payload against now.- Do not confuse it with
consecutive_failed_attemptson the webhook itself, which counts failures across every event and is what eventually disables the webhook. - Retries back off (~1m / 5m / 30m / 2h / 6h), then that event dead-letters.
- After 100 consecutive failed attempts CLC moves the webhook's
statetodisabledand stops delivering entirely. That counts attempts, not events, so retries push it up faster than the number of events suggests. Check it:consecutive_failed_attemptsresets to 0 on any 2xx. There is no update endpoint: delete the webhook and register it again to resume. - After downtime, replay from
GET /v1/webhooks/{id}/events. It is scoped per webhook, so page each one you run.
Events
| Event | Fires when |
|---|---|
borrower.eligible | CLC compliance verification and signature both complete, borrower can request facilities |
facility.decisioned | underwriting approves / declines |
draw.completed / draw.failed | transfer confirmed / structured failure |
repayment.applied / repayment.failed | waterfall applied / repayment could not be applied |
facility.floor_updated | new required floor pushed to the custodian |
margin_warning.raised / .cured / .escalated | ladder transitions |
margin_call.started / .completed | secured-party sale |
facility.closed | lien released |
Updated about 2 months ago