Approva sends signed JSON webhooks for terminal approval-request events in the MVP:
approval_request.approvedapproval_request.rejectedapproval_request.expired
These webhook routes are internal integration points for backend systems, agents, and automation runners.
Every webhook request includes:
Content-Type: application/jsonX-Approval-TimestampX-Approval-Signature
Signature format:
- header value:
v1=<hex_hmac_sha256> - signed input:
<timestamp>.<raw_json_payload> - secret:
WEBHOOK_SIGNING_SECRET
A receiver should:
- read the raw request body before JSON parsing
- read
X-Approval-Timestamp - read
X-Approval-Signature - compute
HMAC_SHA256(secret, timestamp + "." + rawBody) - compare signatures with a timing-safe comparison
- reject requests outside a short timestamp tolerance window
- parse JSON only after signature verification succeeds
The example app includes a copy-paste helper in:
Core verification logic:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyApprovaWebhookSignature(input: {
rawBody: string;
signatureHeader: string | undefined;
timestampHeader: string | undefined;
secret: string;
toleranceSeconds?: number;
}) {
if (!input.signatureHeader || !input.timestampHeader) {
return { valid: false, reason: 'Missing webhook signature headers.' };
}
const expectedSignature = createHmac('sha256', input.secret)
.update(`${input.timestampHeader}.${input.rawBody}`)
.digest('hex');
const providedSignature = input.signatureHeader.replace(/^v1=/i, '');
if (providedSignature.length !== expectedSignature.length) {
return { valid: false, reason: 'Signature length mismatch.' };
}
const valid = timingSafeEqual(
Buffer.from(providedSignature, 'utf8'),
Buffer.from(expectedSignature, 'utf8'),
);
return {
valid,
reason: valid ? null : 'Signature mismatch.',
};
}import { createServer } from 'node:http';
import { verifyApprovaWebhookSignature } from './webhook-signature';
const secret = process.env.WEBHOOK_SIGNING_SECRET ?? 'dev-webhook-signing-secret';
createServer(async (request, response) => {
if (request.method !== 'POST' || request.url !== '/webhooks/approva') {
response.statusCode = 404;
response.end();
return;
}
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const rawBody = Buffer.concat(chunks).toString('utf8');
const verification = verifyApprovaWebhookSignature({
rawBody,
signatureHeader:
typeof request.headers['x-approval-signature'] === 'string'
? request.headers['x-approval-signature']
: undefined,
timestampHeader:
typeof request.headers['x-approval-timestamp'] === 'string'
? request.headers['x-approval-timestamp']
: undefined,
secret,
});
if (!verification.valid) {
response.statusCode = 401;
response.end(JSON.stringify({ ok: false, reason: verification.reason }));
return;
}
const event = JSON.parse(rawBody);
switch (event.eventType) {
case 'approval_request.approved':
console.log('approval granted', event.payload);
break;
case 'approval_request.rejected':
console.log('approval rejected', event.payload);
break;
case 'approval_request.expired':
console.log('approval expired', event.payload);
break;
default:
console.log('ignored event', event.eventType);
}
response.statusCode = 200;
response.end(JSON.stringify({ ok: true }));
}).listen(4100);{
"id": "3f01c902-3c06-4429-a6b5-96f2436fe8a8",
"approvalRequestId": "7e1c48b5-708d-402a-ae32-d5ca90b935ff",
"eventType": "approval_request.approved",
"occurredAt": "2026-03-16T13:45:21.000Z",
"payload": {
"approvalRequestId": "7e1c48b5-708d-402a-ae32-d5ca90b935ff",
"status": "approved",
"capabilityId": "91f8ef51-61cf-4f0a-80c0-8cfd5fe969df",
"capabilityExchangeToken": "cex_8Fsd9Kj3l2PQx0HnV7eTsU6cM4RbYaQ",
"capabilityExchangeExpiresAt": "2026-03-16T13:50:21.000Z"
}
}When the approval request was created with:
callback.deliverCapabilityMode = exchange_token
the approved webhook includes:
capabilityIdcapabilityExchangeTokencapabilityExchangeExpiresAt
Exchange it immediately through POST /v1/capabilities/exchange using the same organization API key or service-account-backed machine principal that created the request.
Approva still does not include the raw capability token directly in the webhook payload, and later fetches still do not reveal it by id.
This signed webhook plus exchange-token flow is the canonical first-party machine continuation path:
Once the example app is running on http://localhost:4100, configure Approva callbacks to use:
http://localhost:4100/webhooks/approva
Then inspect the example state at: