ClocknextHelp
DocumentationAPI ReferenceMCP Tools
Dashboard

Get started

OverviewQuickstart

Good to know

Response formatHandling errorsPaginating resultsSending data reliablyYour own customer idsWebhooks

API reference

Record credit POSTRecord outcome POSTComplete outcome POSTRecord wallet POSTRecord unit POSTComplete composite POST
List customers GETCreate a customer POSTGet a customer GETUpdate a customer PATCHDelete a customer DELETEGet a customer's balances GETGet a customer's wallet ledger GETSet a customer's low-balance alert PUTGet a customer's current plan GETChange a recurring grant PUTList Customer Usage Log GETLook Up Usage Logs by ID GET
Manage a customer's credit balance POSTManage a customer's outcome balance POSTManage a customer's unit balance POSTManage a customer's composite balance POSTManage a customer's wallet balance POST
List a customer's members GETAdd a member POSTUpdate a member PATCHRemove a member DELETE
PlansCreditsOutcomesUnitsComposites
List taxes GETCreate a tax POSTGet a tax GETUpdate a tax PATCH
List purchases GETCreate a purchase POSTGet a purchase GETUpdate a purchase's auto-payment setting PATCHCancel a purchase POST
List invoices GETGet an invoice GETMint a hosted pay link GET
List payments GETGet a payment GET
wallet.balance.update POSTwallet.balance.drop POST
Mint a customer-portal access token POST

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:

EventTriggered when
wallet.balance.updateA customer's Wallet balance increases or decreases.
wallet.balance.dropA 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.

  1. Split X-Clocknext-Signature on , and read the t and v1 values.
  2. Compute HMAC-SHA256(secret, t + "." + rawBody) as lowercase hex.
  3. Compare it to v1 with a constant-time comparison.
  4. Reject requests whose t is 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 "", 200
package 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 2xx within 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 (and X-Clocknext-Event-Id), so record the ids you've handled and skip repeats.
  • Events can arrive out of order. Use triggered_at and the balances in data rather 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`.

On this page

EventsThe requestVerify the signatureRespond, retries, and duplicates