Decision webhook
The decision webhook lets your own systems take part in every payment decision. Before Burnbound authorizes a payment, it sends the payment's facts to an HTTPS endpoint you run, and your code answers allow, deny or hold with your business logic: a project budget, an open ticket, an approved supplier list. It covers paid APIs (x402 and MPP), Stripe Issuing card authorizations and refunds through the action gateway.
The webhook only hardens the decision. deny blocks a payment and hold asks a person to approve it, but allow never lets through a payment your caps or rules refuse. It is part of the Business plan.
How do I set it up?
- In the panel, open Organization → Decision webhook. Only an owner or an admin can see it.
- Enter your endpoint's URL. It must be
httpson port 443, with a public DNS name. Burnbound refuses private addresses, and checks them again on every call. - Copy the secret (
bbdw_…). It is shown only once. - Leave it in observe at first: Burnbound calls it after each decision and keeps its answer in the audit, without changing anything. Use Test webhook to send one sample request (
"test": true, no payment). - When the answers look right, switch to enforce.
Optional settings:
- Only for some subteams or agents. Unchecked, it is called for every agent of the organization.
- Conditions: when to call it, with the same facts and syntax as custom rules (for example, only above 20 USD, or only for new hosts).
- If it fails: deny the payment (the default) or ask a person to approve it.
- Timeout: from 200 to 1,500 ms (1,500 by default).
What does Burnbound send?
A POST with a JSON body and two headers:
POST /burnbound/decision HTTP/1.1
Content-Type: application/json
Burnbound-Webhook: <your webhook id>
Burnbound-Signature: t=1793280000,v1=<hex HMAC-SHA256>
{
"version": 1,
"id": "dwr_01JBX4T6Z8Q9W2E3R4T5Y6V7K8",
"createdAt": "2026-10-29T10:00:00.000Z",
"org": { "id": "<your org id>" },
"agent": { "id": "<agent id>", "memberId": null, "teamId": "<team id>" },
"payment": {
"idempotencyKey": "order-8812",
"host": "api.example.com",
"path": "/v1/query",
"httpMethod": "POST",
"amountUsd": "0.25",
"paymentMethod": "x402.exact",
"rail": "eip155:8453",
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"payeeAddress": "0x…",
"seller": { "listed": true, "category": "data" },
"firstPaymentToHost": false,
"firstPaymentToPayee": false
},
"preliminary": {
"decision": "allow",
"matchedRules": [],
"spentTodayUsd": "4.10"
},
"declared": {
"trust": "agent_declared",
"origin": "user",
"context": { "ticket": "OPS-1423" }
}
}
idis unique for each call.payment.idempotencyKeystays the same when the agent retries the same purchase: use it to deduplicate.- To find a call in the audit: in enforce Burnbound calls your endpoint before the payment exists, so its
decision_webhook.calledordecision_webhook.failedevent has no payment yet and carriesrequestId(theidabove) andidempotencyKey. The payment then names the sameid: asdecisionWebhookRequestIdin itspayment.authorizedevent, or as therequestIdof the webhook's rule in a deny or a hold. paymentholds the facts your custom rules read. For card authorizations it also hasmerchantCategory,merchantCountryandfiatAmount; for refunds through the action gateway,fiatAmountandrefund. It never carries the query string, the seller's text, the payment's signature, keys, emails or billing data.preliminaryis what your caps and rules decided without the webhook.declaredis what the agent says about the payment. See Can I trust the context the agent declares?
What should my endpoint answer?
HTTP 200 with a JSON body of at most 4 KB, signed with the same secret in the same Burnbound-Signature header, and carrying the request's id:
{
"version": 1,
"id": "dwr_01JBX4T6Z8Q9W2E3R4T5Y6V7K8",
"decision": "hold",
"reason": "Ticket OPS-1423 is closed"
}
decision:allow,denyorhold.reason(optional, at most 200 characters) is shown to the agent and in the panel. Burnbound treats it as untrusted text: the agent is told to quote it, never to follow it. It keeps one line of visible text: control characters become spaces, and invisible ones (zero-width, bidirectional, Unicode tag characters, variation selectors and the like) are removed. Do not put secrets or instructions in it.
An answer that is not signed, that carries another id, that is not JSON or that is over 4 KB counts as a failure, never as allow. Burnbound does not follow redirects.
How do I verify the signature?
The signature is an HMAC-SHA256 of <t>.<body> with your secret, in hex, where t is the Unix time in seconds and body is the raw request body. Refuse a t more than 5 minutes away from your clock, and an id you have already seen. This reference endpoint in Node.js does both and signs its answer:
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.BURNBOUND_WEBHOOK_SECRET; // bbdw_…
const seen = new Set(); // request ids; use your database in production
const sign = (t, body) =>
createHmac("sha256", SECRET).update(`${t}.${body}`).digest("hex");
const verify = (header = "", body) => {
const parts = header.split(",").map((part) => part.trim().split("="));
const t = parts.find(([key]) => key === "t")?.[1];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = Buffer.from(sign(t, body), "hex");
return parts.some(([key, value = ""]) => {
const given = Buffer.from(value, "hex");
return (
key === "v1" &&
given.length === expected.length &&
timingSafeEqual(given, expected)
);
});
};
// Your business logic: check the facts against your own systems.
const decide = ({ payment }) =>
Number(payment.amountUsd) > 50
? { decision: "hold", reason: "Over 50 USD: a manager approves it" }
: { decision: "allow" };
createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
const request =
verify(req.headers["burnbound-signature"], body) && JSON.parse(body);
if (!request || seen.has(request.id)) return res.writeHead(401).end();
seen.add(request.id);
const answer = JSON.stringify({
version: 1,
id: request.id,
...decide(request),
});
const t = Math.floor(Date.now() / 1000);
res
.writeHead(200, {
"content-type": "application/json",
"burnbound-signature": `t=${t},v1=${sign(t, answer)}`,
})
.end(answer);
});
}).listen(8080); // behind your HTTPS proxy
During a secret rotation the header carries two v1 values, one per secret, so the endpoint works with either. Burnbound also accepts an answer signed with either one.
Can I trust the context the agent declares?
No. An agent can pass context to fetch_paid (up to 10 keys, values of at most 200 characters):
{
"url": "https://api.example.com/v1/query",
"context": { "ticket": "OPS-1423", "project": "atlas" }
}
Burnbound forwards it under declared, labeled "trust": "agent_declared", and keeps it in the audit. It is what the agent says, not proof: an agent misled by a prompt injection can name any ticket. Your endpoint must check it against your own system (the ticket exists, is open and belongs to that agent or team) before it answers allow. Custom rules in enforce never read it.
What happens when my endpoint fails or is slow?
A timeout, a status other than 200, a redirect, a bad signature, an invalid answer, a private address or a network error is a failure, and Burnbound applies your If it fails setting: deny the payment (the default) or ask a person to approve it. There is no option to allow a payment when the endpoint is down. Over 600 calls a minute per organization, Burnbound does not call the endpoint and the payment counts as a failure (rate_limited).
The agent gets decision_webhook_unavailable with the cause, and the panel shows the last result and the latency of the last 24 hours.
Burnbound does not call the webhook when the answer cannot change anything: when your caps or rules already deny the payment, when the agent repeats a purchase that is already authorized, or when the scope or the conditions do not match.
How do holds, approvals and changes work?
- A
holdcreates an approval, like a custom rule withrequire_approval. When a person approves it and the agent retries, Burnbound asks your endpoint again, and adenythen wins. - Changing the URL, the scope or the conditions makes a new version of the webhook. Approvals given under the previous version, pending or not, no longer cover a payment: the agent asks again.
- Switching between observe and enforce, or changing If it fails or the timeout, does not make a new version.
How does it work with cards and refunds?
For a Stripe Issuing card authorization, Stripe waits about 2 seconds. Burnbound gives your endpoint what is left of that time minus a margin, up to your timeout, measured right before it calls you. If less than 200 ms is left, it does not call your endpoint and counts a timeout. If your endpoint is late, the authorization is declined, whatever your If it fails setting says. A card cannot wait for a person, so a hold declines it too. When your endpoint does not allow a card (a deny, a hold or a failure), Burnbound declines it at once, before it waits for other payments of the same agent, so a busy agent never pushes the answer past Stripe's deadline.
For refunds through the action gateway, hold creates an approval as with paid APIs.
A refund's payment.refund says what the refund is about, read from Stripe by your gateway and computed by Burnbound at the moment of the decision:
{
"originalAgeDays": 31,
"fraction": 0.4,
"disputed": false,
"customerAgeDays": 400,
"customerRefunds90d": { "count": 2, "usd": "13.2", "truncated": false },
"reason": "requested_by_customer"
}
originalAgeDaysandcustomerAgeDays: whole days since the charge and since the customer was created, rounded down.fraction: what has been refunded of the charge, this refund included, from 0 to 1 with 4 decimals, rounded up.customerRefunds90d: the customer's charges of the last 90 days that already had a refund, and their USD (rounded up).truncated: truemeans the customer has more charges than the gateway reads, so the figures are a lower bound.reason: the reason the agent asked with, ornullif it gave none.- A fact your gateway did not send (an older gateway, a Stripe read that failed) is
null. Your custom rules treat it as the restrictive branch; your endpoint decides what to do with it.
Every other payment leaves refund out.
How do I rotate the secret?
Click Rotate secret. Burnbound shows the new secret once and keeps signing with the previous one too for 24 hours, so you can update your endpoint without downtime. If the secret has leaked, choose Rotate without grace: the previous one stops at once.
Every change, rotation and test is recorded in the audit. An agent key cannot read or change the webhook.