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

HeaderDescription
X-Webhook-EventEvent type. Currently: application.status.changed
X-Webhook-TimestampUnix epoch seconds when we created the signature (replay protection)
Jaicob-SignatureHMAC-SHA256 hex-encoded, prefixed with sha256=
Idempotency-KeyStable ID per delivery; use it to dedupe retries

Verify the Signature

  1. Read X-Webhook-Timestamp and X-Webhook-Signature.
  2. Compute HMAC_SHA256(secret, ${timestamp}.${rawBody}).
  3. Compare the expected digest with the signature (after sha256=) using a timing-safe compare.
  4. 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/endpoint

Replace 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.