Authentication & Authorization
Every request is signed with an Ed25519 key you register, and each key carries scopes that decide what it can do. You generate the keypair and upload only the public half, so CLC never holds anything that could sign on your behalf and so a leak on our side is useless to an attacker.
Get a key
You generate the keypair; CLC only ever sees the public half.
openssl genpkey -algorithm ed25519 -out clc-ed25519-private.pem
openssl pkey -in clc-ed25519-private.pem -pubout -out clc-ed25519-public.pemThen register the public half:
- Sign in at dashboard.clc.solutions and open Keys.
- Set the environment with the live/sandbox switch. A key belongs to whichever one was active when you created it, and never crosses over.
- Paste the contents of
clc-ed25519-public.pem, choose the scopes the key needs, and for a live key add the IP addresses it will call from. - CLC hands back a Key ID (
clk_live_...orclk_sandbox_...). That is the value you send inX-CLC-Key-Idon every request.
Keep clc-ed25519-private.pem safe. It is the only thing that can sign your requests, and nobody, CLC included, can recover it for you: if you lose it, generate a new key and revoke the old one.
Environments
CLC runs two fully separate stacks. A key belongs to exactly one and never crosses over.
| Environment | Base URL | Keys |
|---|---|---|
| Live | https://api.clc.solutions | clk_live_…, real borrowers, custodian prod |
| Sandbox | https://api-sandbox.clc.solutions | clk_sandbox_…, throwaway data, custodian sandbox |
Using a key against the wrong stack is a hard 401.
Signing
You sign every request with your Ed25519 private key; CLC verifies it with the public key you uploaded. Send three headers:
X-CLC-Key-Id: your public key id (e.g.clk_live_7f3a9c2e)X-CLC-Timestamp: unix seconds (±1 min window)X-CLC-Signature: base64 of the Ed25519 signature over the canonical request
The canonical request is a newline-delimited string, built in this exact order:
CLC-Ed25519
<timestamp>
<method>
<path + canonicalized query>
<sha256(body) hex>Sign and send a request
A complete, copy-paste example that closes a facility on behalf of your borrower. Point it at your private key and go.
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const (
keyID = "clk_live_7f3a9c2e"
baseURL = "https://api.clc.solutions"
)
// Your Ed25519 private key, generated by you and never sent to CLC.
func loadKey(path string) ed25519.PrivateKey {
pemBytes, _ := os.ReadFile(path)
block, _ := pem.Decode(pemBytes)
key, _ := x509.ParsePKCS8PrivateKey(block.Bytes)
return key.(ed25519.PrivateKey)
}
// One key per logical operation. Reuse the same value when you retry, so CLC
// replays the original response instead of closing the facility twice.
func newIdempotencyKey() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func signedRequest(priv ed25519.PrivateKey, method, path string, body []byte, idempotencyKey string) (*http.Response, error) {
ts := strconv.FormatInt(time.Now().Unix(), 10)
sum := sha256.Sum256(body)
canonical := strings.Join([]string{"CLC-Ed25519", ts, method, path, hex.EncodeToString(sum[:])}, "\n")
sig := ed25519.Sign(priv, []byte(canonical))
req, err := http.NewRequest(method, baseURL+path, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("X-CLC-Key-Id", keyID)
req.Header.Set("X-CLC-Timestamp", ts)
req.Header.Set("X-CLC-Signature", base64.StdEncoding.EncodeToString(sig))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
return http.DefaultClient.Do(req)
}
func main() {
priv := loadKey("clc-ed25519-private.pem")
body := []byte(`{"reason":"borrower_request"}`)
resp, err := signedRequest(priv, "POST", "/v1/facilities/fac_9k2m/close", body, newIdempotencyKey())
if err != nil {
panic(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode, string(out))
}import crypto from "node:crypto";
import { readFileSync } from "node:fs";
// Your Ed25519 private key, generated by you and never sent to CLC.
const privateKey = crypto.createPrivateKey(readFileSync("clc-ed25519-private.pem"));
const KEY_ID = "clk_live_7f3a9c2e";
const BASE_URL = "https://api.clc.solutions";
async function signedRequest(method, path, body = "", idempotencyKey) {
const ts = Math.floor(Date.now() / 1000).toString();
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const canonical = ["CLC-Ed25519", ts, method, path, bodyHash].join("\n");
// Ed25519: the algorithm argument must be null, since the hash is built in.
const signature = crypto.sign(null, Buffer.from(canonical), privateKey);
return fetch(BASE_URL + path, {
method,
headers: {
"X-CLC-Key-Id": KEY_ID,
"X-CLC-Timestamp": ts,
"X-CLC-Signature": signature.toString("base64"),
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: body || undefined,
});
}
// Close a facility on behalf of your borrower.
// One key per logical operation. Reuse the same value when you retry, so CLC
// replays the original response instead of closing the facility twice.
const res = await signedRequest("POST", "/v1/facilities/fac_9k2m/close", '{"reason":"borrower_request"}', crypto.randomUUID());
console.log(res.status, await res.json());import base64, hashlib, time, uuid, requests
from cryptography.hazmat.primitives.serialization import load_pem_private_key
# Your Ed25519 private key, generated by you and never sent to CLC.
with open("clc-ed25519-private.pem", "rb") as f:
private_key = load_pem_private_key(f.read(), password=None)
KEY_ID = "clk_live_7f3a9c2e"
BASE_URL = "https://api.clc.solutions"
def signed_request(method, path, body=b"", idempotency_key=""):
ts = str(int(time.time()))
body_hash = hashlib.sha256(body).hexdigest()
canonical = "\n".join(["CLC-Ed25519", ts, method, path, body_hash])
signature = base64.b64encode(private_key.sign(canonical.encode())).decode()
headers = {
"X-CLC-Key-Id": KEY_ID,
"X-CLC-Timestamp": ts,
"X-CLC-Signature": signature,
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
}
return requests.request(method, BASE_URL + path, data=body, headers=headers)
# Close a facility on behalf of your borrower.
# One key per logical operation. Reuse the same value when you retry, so CLC
# replays the original response instead of closing the facility twice.
r = signed_request("POST", "/v1/facilities/fac_9k2m/close", b'{"reason":"borrower_request"}', str(uuid.uuid4()))
print(r.status_code, r.json())Idempotency
Every call that moves money or creates a resource requires an Idempotency-Key. One key per logical operation, and the same key on every retry.
Retrying with a key CLC has already seen replays the original response instead of running the operation twice. That is what makes a timeout safe to retry: you never learn whether the first attempt landed, and you do not have to.
Reusing a key with a different body is a different situation, and CLC will not guess which request you meant. It returns 409 idempotency_key_conflict rather than silently replaying the old response or silently running the new one:
{
"type": "https://developers.clc.solutions/errors/idempotency_key_conflict",
"title": "Conflict",
"status": 409,
"code": "idempotency_key_conflict",
"detail": "Idempotency key reused with a different request."
}Generate the key at the call site, not inside your signing helper. A helper that mints a fresh key per attempt gives every retry a new one, which defeats the whole mechanism.
Scopes
A key grants exactly the scopes you pick at registration; a call outside them returns 403 insufficient_scope. The tiers are presets over scopes:
IP allowlist
Live keys must declare an IP allowlist; a request from any other source is 403 source_ip_not_allowed (the response echoes the IP we saw). Sandbox keys have no allowlist, so you can integrate from anywhere.
Updated about 2 months ago