API Reference
Webhooks
How webhook events reach your server — the request, verifying the signature, responding, retries and duplicates.
Webhooks push events to your server instead of you polling for them. When an
event is triggered, Clocknext sends an HTTP POST with a JSON body to every endpoint
subscribed to it, signed with that endpoint's secret.
You register endpoints, pick their events, and copy their signing secret in Settings → Webhooks. There is no API for managing endpoints yet — this page covers receiving events.
Events
Each event has its own page with its full payload, under Webhooks in the API reference:
| Event | Triggered when |
|---|---|
wallet.balance.update | A customer's Wallet balance increases or decreases. |
wallet.balance.drop | A customer's Wallet balance falls from above their low-balance alert level, or the endpoint's threshold if they have none, to at or below it. |
Both events watch the customer's ordinary Wallet only. Changes to the Reserved Wallet don't trigger them.
High volume
wallet.balance.update is also triggered by every usage charge on the Wallet,
so expect many events. Check source to pick the ones you need.
The request
Every event is sent the same way, with three headers and the same envelope —
only data differs between events:
POST /webhooks/clocknext HTTP/1.1
Content-Type: application/json
X-Clocknext-Signature: t=1791367200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Clocknext-Event-Id: evt_3f9a1c6e8b2d4f7a9c0e1b2d3f4a5b6c
X-Clocknext-Event-Type: wallet.balance.drop
{
"id": "evt_3f9a1c6e8b2d4f7a9c0e1b2d3f4a5b6c",
"event_type": "wallet.balance.drop",
"triggered_at": "2026-10-07T14:02:51.093Z",
"data": { … }
}X-Clocknext-Event-Id and X-Clocknext-Event-Type repeat the body's id and
event_type, so you can route a request before parsing it.
Verify the signature
Check every request before you trust it. The signature is an HMAC-SHA256,
keyed with the endpoint's full signing secret (including the whsec_ prefix),
over the string ${t}.${rawBody} — the timestamp, a dot, and the raw
request body exactly as received.
- Split
X-Clocknext-Signatureon,and read thetandv1values. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex. - Compare it to
v1with a constant-time comparison. - Reject requests whose
tis too old (for example, more than 5 minutes) to block replays.
Sign the raw body. Parsing the JSON and re-serialising it changes the bytes
and the signature won't match. Each sample below does the same steps. In
Node.js the @clocknext/sdk package does them for you; the other languages write
them out. Pick your language:
import express from "express";
import { constructWebhookEvent, WebhookSignatureError } from "@clocknext/sdk";
const app = express();
const SECRET = process.env.CLOCKNEXT_WEBHOOK_SECRET!; // whsec_…
// express.raw keeps the body as bytes so the signature can be checked.
app.post("/webhooks/clocknext", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
// Checks the signature and rejects events older than 5 minutes.
event = constructWebhookEvent(req.body, req.header("x-clocknext-signature"), SECRET);
} catch (err) {
if (err instanceof WebhookSignatureError) return res.status(400).send("Invalid signature");
throw err;
}
switch (event.event_type) {
case "wallet.balance.update":
// e.g. sync your copy of event.data.current_balance
break;
case "wallet.balance.drop":
// e.g. email the customer to top up
break;
}
res.sendStatus(200);
});
app.listen(3000);// Spring Boot (spring-boot-starter-web)
package com.example;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
public class ClocknextWebhook {
private static final String SECRET = System.getenv("CLOCKNEXT_WEBHOOK_SECRET"); // whsec_…
private static final long TOLERANCE_SECONDS = 5 * 60;
static boolean verify(String rawBody, String header) throws Exception {
if (header == null) return false;
String t = null, v1 = null;
for (String part : header.split(",")) {
String[] kv = part.split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) t = kv[1];
if (kv[0].equals("v1")) v1 = kv[1];
}
if (t == null || v1 == null) return false;
long timestamp;
try {
timestamp = Long.parseLong(t);
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(System.currentTimeMillis() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal((t + "." + rawBody).getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(digest);
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));
}
// Take the body as a String so it is exactly what was signed.
@PostMapping("/webhooks/clocknext")
public ResponseEntity<String> receive(
@RequestBody String rawBody,
@RequestHeader(value = "X-Clocknext-Signature", required = false) String signature)
throws Exception {
if (!verify(rawBody, signature)) {
return ResponseEntity.badRequest().body("Invalid signature");
}
// Parse rawBody with your JSON library, then switch on "event_type":
// wallet.balance.update -> e.g. sync your copy of data.current_balance
// wallet.balance.drop -> e.g. email the customer to top up
return ResponseEntity.ok().build();
}
}# Flask
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["CLOCKNEXT_WEBHOOK_SECRET"] # whsec_…
TOLERANCE_SECONDS = 5 * 60
def verify(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or not t.isdigit():
return False
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/webhooks/clocknext")
def clocknext_webhook():
# get_data() returns the body exactly as received, which the signature needs.
raw_body = request.get_data()
if not verify(raw_body, request.headers.get("X-Clocknext-Signature")):
return "Invalid signature", 400
event = json.loads(raw_body)
if event["event_type"] == "wallet.balance.update":
pass # e.g. sync your copy of event["data"]["current_balance"]
elif event["event_type"] == "wallet.balance.drop":
pass # e.g. email the customer to top up
return "", 200package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = os.Getenv("CLOCKNEXT_WEBHOOK_SECRET") // whsec_…
const toleranceSeconds = 5 * 60
func verify(rawBody []byte, header string) bool {
if header == "" {
return false
}
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(p, "="); ok {
parts[k] = v
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || parts["v1"] == "" {
return false
}
if math.Abs(float64(time.Now().Unix()-t)) > toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
func webhook(w http.ResponseWriter, r *http.Request) {
// Read the body exactly as received: the signature covers these bytes.
rawBody, err := io.ReadAll(r.Body)
if err != nil || !verify(rawBody, r.Header.Get("X-Clocknext-Signature")) {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
var event struct {
EventType string `json:"event_type"`
}
if err := json.Unmarshal(rawBody, &event); err != nil {
http.Error(w, "Bad payload", http.StatusBadRequest)
return
}
switch event.EventType {
case "wallet.balance.update":
// e.g. sync your copy of data.current_balance
case "wallet.balance.drop":
// e.g. email the customer to top up
}
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("POST /webhooks/clocknext", webhook)
http.ListenAndServe(":3000", nil)
}Respond, retries, and duplicates
- Answer with any
2xxwithin 5 seconds. Anything else — a non-2xx status, a timeout, or a connection error — counts as a failed delivery. Do slow work after you respond. - Failed deliveries are retried automatically, up to 5 more times with increasing delays. Every attempt, and your server's response to it, is listed under Message Attempts on the endpoint's page, where you can also retry a failed delivery by hand.
- The same event can arrive more than once. Retries reuse the event's
id(andX-Clocknext-Event-Id), so record the ids you've handled and skip repeats. - Events can arrive out of order. Use
triggered_atand the balances indatarather than arrival order. For the current balance, read it from Get customer balances. - Disabled endpoints get nothing. Events triggered while an endpoint is disabled are dropped, not saved for later.
Rotating the secret
Rotating an endpoint's secret in Settings takes effect immediately — every delivery after that, including retries of earlier events, is signed with the new secret. Update your server's secret straight away.
Your own customer ids
Give a customer an externalId — the id your product already uses — and name it by that instead of ours.
Record credit POST
Records usage against a named Credit balance. `agentKey` must be sold on the customer's active plan, or covered by a composite that is. Priced automatically from the model's rates and token counts — you can't post a price yourself. If the model has both cache-read and cache-write rates, `cacheTokens` carries the cache-**read** tokens only (billed at the cache-read rate); send cache-write tokens separately as `cacheWriteTokens`.