Webhooks for External Integrations
Receive real-time notifications from our platform via HTTP POST requests. This page explains how to verify signatures and handle the single event we currently emit.
Overview
- Protocol: HTTPS POST with JSON body
- Authentication: HMAC-SHA256 signature using your subscription secret
- Retries: Exponential backoff; duplicate deliveries are possible — use Idempotency-Key
- Respond quickly with 2xx to acknowledge receipt
HTTP Request Example
POST /your/webhook/endpoint HTTP/1.1
Host: jaicob.ai
User-Agent: Jaicob/1.0
Content-Type: application/json
Jaicob-Signature: sha256=4cc1c16a... (hex)
X-Webhook-Event: application.status.changed
X-Webhook-Timestamp: 1728388800
Idempotency-Key: b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c{
"id": "b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c",
"type": "application.status.changed",
"occurredAt": "2025-10-07T09:42:31.000Z",
"data": {
"id": "f1bf5b1f-0d86-4f2a-86e7-5c0f2a2f2de1",
"status": "SCREENED",
"changedAt": "2025-10-07T09:42:31.000Z"
}
}Headers
| Header | Description |
|---|---|
| X-Webhook-Event | Event type. Currently: application.status.changed |
| X-Webhook-Timestamp | Unix epoch seconds when we created the signature (replay protection) |
| Jaicob-Signature | HMAC-SHA256 hex-encoded, prefixed with sha256= |
| Idempotency-Key | Stable ID per delivery; use it to dedupe retries |
Verify the Signature
- Read X-Webhook-Timestamp and X-Webhook-Signature.
- Compute HMAC_SHA256(secret,
${timestamp}.${rawBody}). - Compare the expected digest with the signature (after sha256=) using a timing-safe compare.
- Reject requests older than 5 minutes (configurable) to prevent replay attacks.
Node.js (Next.js API Route)
Ensure you can access the raw body when verifying.
import crypto from 'node:crypto';
import type { NextApiRequest, NextApiResponse } from 'next';
// Env: WEBHOOK_SECRET=<the secret you received when subscribing>
const WEBHOOK_TOLERANCE_SECONDS = 300; // 5 minutes
function timingSafeEqual(a: string, b: string) {
const A = Buffer.from(a, 'hex');
const B = Buffer.from(b, 'hex');
return A.length === B.length && crypto.timingSafeEqual(A, B);
}
function verifySignature(bodyRaw: string, timestamp: string, header: string, secret: string) {
const [algo, sig] = header.split('=');
if (algo !== 'sha256' || !sig) return false;
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age > WEBHOOK_TOLERANCE_SECONDS || age < -WEBHOOK_TOLERANCE_SECONDS) return false; // clock skew
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${bodyRaw}`).digest('hex');
return timingSafeEqual(sig, expected);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end();
// IMPORTANT: get raw body (disable bodyParser or use a custom config in Next.js)
const rawBody = (req as any).rawBody?.toString('utf8') ?? JSON.stringify(req.body);
const timestamp = req.headers['x-webhook-timestamp'] as string;
const signature = req.headers['jaicob-signature'] as string;
if (!timestamp || !signature || !verifySignature(rawBody, timestamp, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(400).json({ error: 'Invalid signature' });
}
// Idempotency handling (recommended)
const idempotencyKey = req.headers['idempotency-key'] as string;
// TODO: check if idempotencyKey is already processed in your store; if yes, return 200 immediately.
const event = JSON.parse(rawBody);
if (event.type === 'application.status.changed') {
// handle event.data
}
return res.status(200).json({ ok: true });
}Python
import hmac, hashlib, time
from typing import Tuple
TOLERANCE = 300
def verify_signature(raw_body: bytes, timestamp: str, header: str, secret: str) -> bool:
try:
algo, sig = header.split('=')
except ValueError:
return False
if algo != 'sha256':
return False
age = int(time.time()) - int(timestamp)
if abs(age) > TOLERANCE:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
# timing-safe
return hmac.compare_digest(sig, expected)
Event Catalog
application.status.changed
Emitted whenever an entity's status changes.
Payload schema
- id (string, UUID): Delivery identifier, stable across retries
- type (string): Always application.status.changed
- occurredAt (ISO 8601): When the change occurred
- data.id (string, UUID): The entity ID
- data.status (string)
- data.changedAt (ISO 8601)
Example
{
"id": "b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c",
"type": "application.status.changed",
"occurredAt": "2025-10-07T09:42:31.000Z",
"data": {
"id": "f1bf5b1f-0d86-4f2a-86e7-5c0f2a2f2de1",
"status": "SCREENED",
"changedAt": "2025-10-07T09:42:31.000Z"
}
}Testing Locally
You can simulate a delivery using curl:
curl -X POST
-H 'Content-Type: application/json'
-H 'Jaicob-Signature: sha256=4cc1c16a...'
-H 'X-Webhook-Event: application.status.changed'
-H 'X-Webhook-Timestamp: 1728388800'
-H 'Idempotency-Key: b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c'
-d '{ "id": "b8a3c9a6-3e6c-44a6-b077-6a7c2f0b0c9c", "type": "application.status.changed", "occurredAt": "2025-10-07T09:42:31.000Z", "data": { "id": "f1bf5b1f-0d86-4f2a-86e7-5c0f2a2f2de1", "status": "SCREENED", "changedAt": "2025-10-07T09:42:31.000Z" }}'
https://example.com/your/webhook/endpointReplace headers and payload values with real ones from your subscription.
Best Practices
- Respond with 200 as soon as you enqueue processing; do heavy work asynchronously.
- Use the Idempotency-Key to discard duplicate deliveries.
- Validate Content-Type and required headers before parsing.
- Log the delivery metadata (status, headers), not secrets.
- Rotate your webhook secret periodically.
Updated 30 days ago
