Examples
Copyable, working examples: a first purchase, checking before buying, following an approval, idempotent retries, OAuth connection and webhook signature verification.
Working examples against the real contract. Every identifier, merchant and domain below is invented and safe to copy; replace the credentials with your own.
1. A first purchase#
curl -X POST https://app.veriticity.com/v1/purchase-requests \
-H "Authorization: Bearer $VERITICITY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: renewal-2026-09" \
-d '{
"merchant": "Acme Cloud",
"merchantDomain": "acme-cloud.example.com",
"amountMinor": "7500",
"currency": "GBP",
"category": "cloud-infrastructure",
"reason": "Renew build pipeline compute credits"
}'const response = await fetch(
"https://app.veriticity.com/v1/purchase-requests",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VERITICITY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "renewal-2026-09",
},
body: JSON.stringify({
merchant: "Acme Cloud",
merchantDomain: "acme-cloud.example.com",
amountMinor: "7500",
currency: "GBP",
category: "cloud-infrastructure",
reason: "Renew build pipeline compute credits",
}),
},
);
const result = await response.json();
console.log(result.decision.outcome); // "APPROVED"
console.log(result.decision.explanation); // a sentence you can show a personimport { Veriticity } from "@veriticity/sdk";
const veriticity = new Veriticity({ apiKey: process.env.VERITICITY_API_KEY! });
const result = await veriticity.purchases.submit({
merchant: "Acme Cloud",
amountMinor: 7500n,
currency: "GBP",
reason: "Renew build pipeline compute credits",
});
switch (result.decision.outcome) {
case "APPROVED":
break;
case "REJECTED":
break;
case "REQUIRES_APPROVAL":
break;
}Branch on decision.outcome, never on the status code. All three outcomes are 201.
2. Check before buying#
A simulation runs the same evaluation and writes nothing. Use it when an agent needs to decide whether to ask.
curl -X POST https://app.veriticity.com/v1/simulations/current-policies \
-H "Authorization: Bearer $VERITICITY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"merchant": "Northwind Supplies",
"amountMinor": "180000",
"currency": "GBP",
"reason": "Quarterly stationery order"
}'{
"simulation": { "isSimulation": true, "mode": "CURRENT_POLICIES" },
"simulatedDecision": {
"outcome": "REQUIRES_APPROVAL",
"wouldRequireApproval": true,
"reasonCodes": ["APPROVAL_THRESHOLD_EXCEEDED"],
"explanation": "GBP 1,800.00 is above the GBP 250.00 threshold that requires a person to approve."
},
"budgets": [
{
"periodKind": "MONTH",
"currency": "GBP",
"current": { "committedMinor": "340000", "heldMinor": "0" },
"simulated": { "requestedMinor": "180000", "projectedMinor": "520000" },
"limits": [
{ "limitMinor": "1000000", "remainingAfterMinor": "480000", "wouldExceed": false }
],
"wouldExceed": false
}
]
}budgets[] is the honest answer to “how much is left”: what the window holds now, what this would add, and what the limits say about the sum.
3. Following an approval#
/**
* Follow an escalated purchase to its outcome.
*
* Polling is shown because it is the simplest thing that works. In production
* subscribe to purchase.approved and purchase.rejected instead -- being told
* beats asking, and an approval can take hours.
*/
async function waitForOutcome(
purchaseRequestId: string,
expiresAt: string,
): Promise<"APPROVED" | "REJECTED" | "EXPIRED"> {
const deadline = new Date(expiresAt).getTime();
while (Date.now() < deadline) {
const response = await fetch(
`https://app.veriticity.com/v1/purchase-requests/${purchaseRequestId}`,
{ headers: { Authorization: `Bearer ${process.env.VERITICITY_API_KEY}` } },
);
const { purchase } = await response.json();
// approval.isPending is the authoritative "is a person still deciding".
// Reading decision.outcome alone would see the original REQUIRES_APPROVAL
// and never notice the re-evaluation.
if (purchase.approval && !purchase.approval.isPending) {
return purchase.decision.outcome;
}
await new Promise((resolve) => setTimeout(resolve, 30_000));
}
// Nobody answered. The budget hold has been released and the purchase will
// not happen unless it is submitted again.
return "EXPIRED";
}Prefer webhooks in production. Subscribe to purchase.approved and purchase.rejected and you will be told rather than having to ask.
4. Idempotent submission#
/**
* Submit a purchase that is safe to retry.
*
* The key is derived from something stable in our own system, so every retry
* of *this* purchase carries the same key. A random key per attempt would turn
* each retry into a separate purchase, which is the whole thing the header
* exists to prevent.
*/
async function submitPurchase(invoiceId: string, amountMinor: string) {
const idempotencyKey = `invoice-${invoiceId}`;
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(
"https://app.veriticity.com/v1/purchase-requests",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VERITICITY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
merchant: "Northwind Supplies",
amountMinor,
currency: "GBP",
reason: `Payment for invoice ${invoiceId}`,
}),
},
);
// 201 = evaluated now. 200 = we already asked; this is the stored answer,
// and no additional budget was reserved.
if (response.status === 201 || response.status === 200) {
return response.json();
}
if (response.status >= 500) {
// Ours. Safe to retry -- the key guarantees we cannot double-spend.
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
continue;
}
const { error } = await response.json();
if (error.code === "idempotency_key_reuse") {
// This key was used for a *different* purchase. Retrying can never
// succeed: the key derivation is wrong, or the amount changed.
throw new Error(`Key collision for invoice ${invoiceId}`);
}
throw new Error(`${error.code}: ${error.message}`);
}
throw new Error("Veriticity did not answer after three attempts");
}5. Reading policies#
# Organisation-scoped key. An agent-bound key is refused here.
curl "https://app.veriticity.com/v1/policies?status=ACTIVE&pageSize=10" \
-H "Authorization: Bearer $VERITICITY_ORG_KEY"{
"policies": [
{
"id": "0199a3e0-1f4c-7a11-b3d2-5e8c7a4f2b90",
"name": "Company baseline",
"status": "ACTIVE",
"inForce": true,
"activeVersion": { "versionNumber": 4, "ruleCount": 3, "merchantCount": 12 }
}
],
"pagination": { "page": 1, "pageSize": 10, "total": 1, "totalPages": 1 }
}Read inForce rather than deriving enforcement from status and activeVersion — a DISABLED policy keeps its active version but is not enforced.
6. Creating an OAuth connection#
import { createHash, randomBytes } from "node:crypto";
const CLIENT_ID = "https://your-app.example.com/client.json";
const REDIRECT_URI = "https://your-app.example.com/callback";
/** Step 1: build a PKCE pair and send the person to authorise. */
export function startConnection(): { url: string; verifier: string; state: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
const url = new URL("https://app.veriticity.com/oauth/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
url.searchParams.set("scope", "purchase:check purchase:request purchase:read");
// RFC 8707. Exactly this value -- anything else is refused with
// invalid_target.
url.searchParams.set("resource", "https://app.veriticity.com/mcp");
// Store verifier and state against the session. Both must survive the
// round trip and neither may reach the browser.
return { url: url.toString(), verifier, state };
}
/** Step 2: exchange the code. The person chose the agent, not us. */
export async function completeConnection(
code: string,
returnedState: string,
returnedIss: string,
expected: { verifier: string; state: string },
) {
if (returnedState !== expected.state) {
throw new Error("state mismatch");
}
// RFC 9207. Veriticity always sends iss, so a response without it -- or
// with the wrong one -- is a mix-up attack and must be refused.
if (returnedIss !== "https://app.veriticity.com") {
throw new Error("issuer mismatch");
}
const response = await fetch("https://app.veriticity.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
code_verifier: expected.verifier,
client_id: CLIENT_ID,
resource: "https://app.veriticity.com/mcp",
}),
});
const tokens = await response.json();
// Store the refresh token ATOMICALLY. It rotates on every use, and
// presenting a consumed one revokes the entire connection.
await storeTokens(tokens);
return tokens;
}7. Verifying a webhook#
import { Webhooks, isKnownWebhookEvent } from "@veriticity/sdk/webhooks";
const webhooks = new Webhooks({ secret: process.env.VERITICITY_WEBHOOK_SECRET! });
export async function POST(request: Request) {
// Verifies and parses in one step, from the raw bytes.
const event = await webhooks.unwrap(request);
if (isKnownWebhookEvent(event) && event.type === "purchase.approved") {
// event.data is narrowed to this event's payload.
console.log(event.data.purchaseRequestId);
}
return new Response(null, { status: 204 });
}The same verification written by hand, for any other language or for a project that would rather not take the dependency:
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/**
* Verify a Veriticity webhook signature.
*
* rawBody must be the exact bytes received. Most frameworks parse JSON before
* a handler sees it and discard the bytes; re-serialising will not verify,
* because JSON.parse followed by JSON.stringify is not the identity function.
*/
export function verifyWebhook(
rawBody: string,
headers: { timestamp: string | null; signature: string | null },
secret: string,
now: Date = new Date(),
): boolean {
if (headers.timestamp === null || headers.signature === null) {
return false;
}
const age = Math.abs(
Math.floor(now.getTime() / 1000) - Number(headers.timestamp),
);
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
return false;
}
// The scheme version is inside the signed material, not only in the header.
const expected = createHmac("sha256", secret)
.update(`v1:${headers.timestamp}:${rawBody}`, "utf8")
.digest("base64url");
// Two signatures arrive during the 24 hours after a rotation. Either one
// verifying is a success -- that is what makes a zero-downtime rotation
// possible.
return headers.signature
.split(",")
.map((part) => part.trim())
.filter((part) => part.startsWith("v1="))
.map((part) => part.slice(3))
.some((candidate) => {
const a = Buffer.from(candidate);
const b = Buffer.from(expected);
// timingSafeEqual throws on a length mismatch rather than returning
// false, so lengths are compared first.
return a.length === b.length && timingSafeEqual(a, b);
});
}Then deduplicate on the Veriticity-Webhook-Id header: delivery is at-least-once, so the same event can arrive twice.
8. Creating and testing an endpoint#
# 1. Create the endpoint. The secret is in this response and nowhere else.
curl -X POST https://app.veriticity.com/v1/webhooks \
-H "Authorization: Bearer $VERITICITY_ORG_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/veriticity",
"description": "Ledger sync",
"subscribedTypes": ["purchase.approved", "purchase.rejected"]
}'
# 2. Send a real signed event through the real pipeline. Answers 202: the
# event is queued, not delivered.
curl -X POST https://app.veriticity.com/v1/webhooks/$WEBHOOK_ID/test \
-H "Authorization: Bearer $VERITICITY_ORG_KEY"
# 3. See what happened, including your own error body if it failed.
curl "https://app.veriticity.com/v1/webhooks/$WEBHOOK_ID/deliveries?limit=5" \
-H "Authorization: Bearer $VERITICITY_ORG_KEY"
# 4. Rotate. Both secrets sign for 24 hours, so deploy the new one at leisure.
curl -X POST https://app.veriticity.com/v1/webhooks/$WEBHOOK_ID/rotate-secret \
-H "Authorization: Bearer $VERITICITY_ORG_KEY"
# 5. Revoke. Terminal -- the secret is destroyed and cannot be restored.
curl -X DELETE https://app.veriticity.com/v1/webhooks/$WEBHOOK_ID \
-H "Authorization: Bearer $VERITICITY_ORG_KEY"Full detail on every one of these is in the API reference.