Webhooks
Receive real-time HTTP callbacks when approval decisions are made, with HMAC-signed payloads for verification.
Webhooks let your application receive real-time notifications when approval decisions are made, instead of polling the status endpoint. When a request is approved or rejected, SignedApproval sends an HTTP POST to your configured URL with the full signed decision payload.
There are two webhook types: a per-request callback set via callback_url when creating a request, and dashboard webhooksconfigured under Integrations → Webhooks that fire for all approval events.
Webhook Types
- callback_url — Include a
callback_urlin your approval request. Fires exactly once when that specific request is decided. HMAC-signed with your per-API-key signing secret (see below). - Dashboard Webhooks— Configure under Integrations → Webhooks. Fires for all approval events across your account. Each webhook has its own unique signing secret shown at creation time.
Callback Payload
When a decision is made, SignedApproval POSTs this payload to your callback_url:
{
"request_id": "2c43385a-...",
"decision": "approved",
"decided_at": "2026-03-24T14:02:30.000Z",
"signed_receipt": {
"decision_id": "dec_xyz789",
"signature": "base64-ed25519-signature",
"public_key_id": "key-uuid",
"canonical_payload": "eyJ2IjoxLCJyaWQiOi...",
"comment": null
}
}The signed_receipt object can be passed directly to GET /api/v1/approvals/{id}/verify for offline Ed25519 verification.
Getting Your Webhook Signing Secret
Callbacks sent to callback_url are HMAC-signed with a per-API-key secret derived from your key. To retrieve it:
GET https://signedapproval.net/api/v1/account/webhook-secret
Authorization: Bearer sa_live_your_key
# Response:
{ "webhook_signing_secret": "hex-string" }For user JWT auth, the endpoint returns secrets for all your non-revoked API keys:
{
"keys": [
{
"id": "key-uuid",
"name": "Production",
"key_prefix": "sa_live_abc1",
"webhook_signing_secret": "hex-string"
}
]
}HMAC Signature Verification
Every webhook includes an X-SignedApproval-Signature header in the format sha256=hex_digest and an X-SignedApproval-Timestamp header (Unix seconds). The HMAC is computed over {timestamp}.{body}. Reject requests older than 5 minutes.
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhook(body: string, signatureHeader: string, timestamp: string, secret: string): boolean {
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
// Reject replays older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
return timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}
// In your webhook handler:
app.post('/webhook/signedapproval', (req, res) => {
const signature = req.headers['x-signedapproval-signature'];
const timestamp = req.headers['x-signedapproval-timestamp'];
const rawBody = req.rawBody; // Must be the raw string, not parsed JSON
if (!verifyWebhook(rawBody, signature, timestamp, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
// Process the event...
res.status(200).send('OK');
});import hmac
import hashlib
import time
def verify_webhook(body: bytes, signature_header: str, timestamp: str, secret: str) -> bool:
# Reject replays older than 5 minutes
if abs(time.time() - float(timestamp)) > 300:
return False
payload = f"{timestamp}.".encode() + body
expected = 'sha256=' + hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature_header, expected)Per-Request Callback Example
# Include callback_url when creating a request
curl -X POST https://signedapproval.net/api/v1/approvals/request \
-H "Authorization: Bearer sa_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"action": "Deploy to production",
"ttl_seconds": 3600,
"callback_url": "https://your-app.com/webhook/approval"
}'Delivery and Retries
SignedApproval retries failed deliveries with exponential backoff: 1s, 5s, 30s, then 5 minutes. All attempts are logged in signedapproval_webhook_deliveries. For callbacks, the webhook fires exactly once per request when quorum is reached — not once per individual approver.
X-SignedApproval-Signature header before processing webhook payloads. Without verification, an attacker could forge webhook requests to trick your system into believing an approval was granted.timingSafeEqual (Node.js) or hmac.compare_digest (Python) for HMAC comparison. Regular string comparison is vulnerable to timing attacks.