Your agent sends a payment and your own server is the last one to know. The agent holds the API key, the transfer returns a hash, and your only record is a line in the agent's own log, which the agent wrote. Polling transaction history only scales so far.
OpenClawCash wallet webhooks close that gap. You register one https URL, the service POSTs a signed JSON event to it whenever a transaction is recorded on one of your wallets, and your backend reacts. This is the whole path, calls and receiver code taken from the public docs.
What you will build
One endpoint you control that receives wallet.transaction.confirmed, checks the signature, de-duplicates the delivery and answers 2xx. No polling loop, no second database of "did the agent really pay".
Step 1. Get an API key and find your wallet
Every agent call carries the same header, X-Agent-Key. Create a key in the dashboard, then list the wallets it can reach:
curl https://openclawcash.com/api/agent/wallets \
-H "X-Agent-Key: occ_your_api_key"
Each entry returns an id such as Q7X2K9P, the address, the network and the chain. Keep the id, it is what the rest of the API expects.
Step 2. Write the receiver, raw body first
The docs are strict about the order here, and it matters: read the raw request body before any JSON parsing, reject a webhook-timestamp older than five minutes, de-duplicate on webhook-id, and answer with a 2xx within 10 seconds. A non-2xx answer, or no answer in that window, counts as a failed delivery.
This is a complete receiver in Node. Save it as receiver.mjs and start it with OCC_WEBHOOK_SECRET=whsec_... node receiver.mjs:
import http from "node:http";
import crypto from "node:crypto";
const SECRET = process.env.OCC_WEBHOOK_SECRET; // whsec_...
const seen = new Set(); // de-duplicate on webhook-id
function verifyWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = Number(headers["webhook-timestamp"]);
if (!id || !Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(id + "." + timestamp + "." + rawBody).digest("base64");
return String(headers["webhook-signature"] || "").split(" ").some((entry) => {
const given = Buffer.from(entry.split(",")[1] || "");
const want = Buffer.from(expected);
return given.length === want.length && crypto.timingSafeEqual(given, want);
});
}
http.createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8");
if (!verifyWebhook(SECRET, req.headers, rawBody)) {
res.writeHead(401).end("bad signature");
return;
}
if (seen.has(req.headers["webhook-id"])) {
res.writeHead(200).end("ok");
return;
}
seen.add(req.headers["webhook-id"]);
const event = JSON.parse(rawBody);
console.log(event.eventType, event.data.direction, event.data.value);
res.writeHead(200).end("ok");
});
}).listen(3001, () => console.log("listening on :3001"));
The verifyWebhook function is the one the docs publish, unchanged. It reads the key as the base64-decoded part of the secret after whsec_, and compares signatures in constant time. The older x-occ-signature header is still sent.
Step 3. Register the endpoint
Your URL has to be https and reachable from the internet. Plain http, private addresses and redirects are refused, which is why step 2 comes before this one.
curl -X POST https://openclawcash.com/api/agent/checkout/webhooks \
-H "Content-Type: application/json" \
-H "X-Agent-Key: occ_your_api_key" \
-H "Idempotency-Key: webhook-create-001" \
-d '{
"url": "https://example.com/occ-webhook",
"eventTypes": ["wallet.transaction.confirmed"],
"enabled": true
}'
The response carries a publicId such as wh_a1b2c3d4e5f6 and a secret starting with whsec_. That secret is shown once. Store it on your server, and store it as a secret: not in the repo, not in the agent's memory.
One detail that catches people: wallet.transaction.confirmed has to be named. A * subscription covers escrow events only, and wallet events are never delivered to it.
Step 4. Confirm the subscription
curl "https://openclawcash.com/api/agent/checkout/webhooks" \
-H "X-Agent-Key: occ_your_api_key"
You should see your endpoint with "enabled": true and the subscribed eventTypes. The signing secret is never returned again; if it is lost, delete the webhook and create a new one.
Step 5. Get a real delivery
Open https://openclawcash.com/webhooks, find the endpoint and press Test, or make a small transfer on a test network such as Sepolia. Your server logs one line and answers 200.
The body is { eventId, eventType, createdAt, data }, and for a wallet event data looks like this:
{
"eventId": "evt_...",
"eventType": "wallet.transaction.confirmed",
"createdAt": "...",
"data": {
"walletId": "Q7X2K9P",
"walletAddress": "0x...",
"network": "sepolia",
"transactionId": 7,
"type": "transfer",
"status": "confirmed",
"direction": "outgoing",
"hash": "0x...",
"from": "0x...",
"to": "0x...",
"value": "1000000000000000",
"fee": "0",
"platformFee": "0"
}
}
value, fee and platformFee are strings in base units, so do the decimal math yourself with the token's decimals. direction is incoming or outgoing.
Limits worth knowing before you rely on it
- A transfer between two wallets you hold writes a row for each of them, so you get two events for one payment, one
outgoingand oneincoming, carrying the samehash. Match onhashif you count money. - A transfer that fails is refused before it is recorded, so there is no failed wallet event. These events report what landed.
- Failed deliveries are retried with growing delays for about a day. A
410from your server is different: it disables the endpoint and closes the deliveries already queued for it. - Wallet webhooks are managed apart from escrow webhooks: two surfaces on purpose, configured in different dashboard places.
One endpoint, one subscription, one verifier. The full reference, including the update and delete calls, is at https://openclawcash.com/docs.
Top comments (0)