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.

HeaderContents
X-CLC-Webhook-Signaturesha256=<hex>
X-CLC-Webhook-Timestampunix seconds
X-CLC-Event-Idthe event id, stable across retries. Deduplicate on it
X-CLC-Delivery-Attemptwhich 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 Content

Reference

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 on resource_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-Attempt counts 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, compare occurred_at in the payload against now.
  • Do not confuse it with consecutive_failed_attempts on 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 state to disabled and stops delivering entirely. That counts attempts, not events, so retries push it up faster than the number of events suggests. Check it: consecutive_failed_attempts resets 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

EventFires when
borrower.eligibleCLC compliance verification and signature both complete, borrower can request facilities
facility.decisionedunderwriting approves / declines
draw.completed / draw.failedtransfer confirmed / structured failure
repayment.applied / repayment.failedwaterfall applied / repayment could not be applied
facility.floor_updatednew required floor pushed to the custodian
margin_warning.raised / .cured / .escalatedladder transitions
margin_call.started / .completedsecured-party sale
facility.closedlien released


Did this page help you?