Webhooks

Receive real-time HTTP callbacks when approval decisions are made, with HMAC-signed payloads for verification.

Key Concepts

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_url in 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:

JSON
{
  "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:

bash
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:

JSON
{
  "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.

javascript
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');
});
python
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

bash
# 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.

Important
Always verify the 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.
Tip
Use timingSafeEqual (Node.js) or hmac.compare_digest (Python) for HMAC comparison. Regular string comparison is vulnerable to timing attacks.