Webhooks

Webhooks let your infrastructure react to asynchronous Hakim events — job completion, voice-clone readiness, invoice lifecycle, without polling.

Creating a subscription

From the dashboard: Developers → Webhooks → New webhook. You pick the events you care about, the HTTPS URL we should POST to, and we return a signing secret you'll need to verify incoming deliveries.

Programmatically, via the API:

curl -X POST https://api.tryhakim.ai/v1/webhooks \
  -H "Authorization: Bearer $HAKIM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/hakim",
    "events": ["job.succeeded", "job.failed", "voice_clone.ready"],
    "description": "Production pipeline"
  }'

The response includes a plain-text secret, this is the only time you'll see it. Store it in your secret manager immediately. Losing it means deleting and recreating the subscription.

Event catalogue

EventFires when
job.succeededA batch TTS / STT job completed successfully
job.failedA job aborted (input rejected, quota, upstream failure)
voice_clone.readyA voice clone finished training
voice_clone.failedA voice clone was rejected (consent, quality)
invoice.finalizedA Stripe invoice transitioned to open
invoice.paidA Stripe invoice was paid
invoice.payment_failedA Stripe payment attempt failed
subscription.updatedPlan changed (upgrade, downgrade, cancel scheduled)
subscription.canceledSubscription fully ended (period rollover after cancel)
usage.hard_cap_exceededOrg hit its configured monthly hard cap

The /docs/api reference lists the full payload schema for each event.

Delivery shape

Every delivery POSTs JSON with this envelope:

{
  "id": "evt_01HNQ9S4ZXN4F7N6M5CY8J6F3P",
  "type": "job.succeeded",
  "created_at": "2026-04-17T10:43:22.117Z",
  "data": {
    "object": {/* event-specific payload */}
  },
  "delivery_attempt": 1
}

Headers you'll see on every request:

  • X-Hakim-Signature, hex-encoded HMAC-SHA256 of the raw request body using your signing secret.
  • X-Hakim-Timestamp, Unix timestamp (seconds) the delivery was signed. Reject deliveries older than 5 minutes to prevent replay.
  • X-Hakim-Event-Id, same as id in the envelope. Use it for idempotency on your side.

Verifying the signature

Every production integration must verify the signature before acting on a delivery. Anything less is a spoofing vulnerability.

import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.HAKIM_WEBHOOK_SECRET!;

export function verifyWebhook(rawBody: Buffer, headers: Headers): boolean {
  const signature = headers.get('x-hakim-signature');
  const timestamp = headers.get('x-hakim-timestamp');
  if (!signature || !timestamp) return false;

  const ageMs = Date.now() - Number(timestamp) * 1000;
  if (ageMs > 5 * 60 * 1000) return false; // Reject replays older than 5 minutes.

  const expected = createHmac('sha256', SECRET)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');

  return timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
}

The signature is computed over timestamp + "." + raw_body, feeding a JSON-parsed object back in won't match because key order and whitespace differ. Always hold onto the raw bytes before parsing.

Python equivalent:

import hmac, hashlib, os, time

SECRET = os.environ["HAKIM_WEBHOOK_SECRET"].encode()

def verify_webhook(raw_body: bytes, signature: str, timestamp: str) -> bool:
    if abs(time.time() - int(timestamp)) > 5 * 60:
        return False
    expected = hmac.new(
        SECRET,
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

Retries and idempotency

We retry failed deliveries with exponential backoff:

  • Attempt 1, immediately
  • Attempt 2, 30 seconds
  • Attempt 3, 2 minutes
  • Attempt 4, 10 minutes
  • Attempt 5, 1 hour

A delivery is considered failed when:

  • Your server returns a 5xx status, or
  • The request times out (we wait up to 15 seconds for your response), or
  • The TCP connection is refused.

Any 2xx is a success, including 204. 4xx responses are not retried — they indicate your handler rejected the event and retrying won't change that.

Because we retry, your handler MUST be idempotent. The simplest pattern is to store the X-Hakim-Event-Id value and check for duplicates before acting.

Rotating the secret

From the dashboard, Webhooks → … → Rotate secret. We return the new secret once; the old one remains valid for 24 hours so you can deploy the new value across your fleet without dropped events.

Replaying a delivery

Every subscription has a deliveries log under Webhooks → Deliveries in the dashboard. Open any delivery and click Replay to refire it. The replay creates a fresh delivery row (the attempt trail is preserved) so you can watch it progress through the retry ladder.

Cookbook

Recipe · Batch render a podcast season + notify on done

Tier: Faseeh v2 for narration. The pattern below submits long-form TTS via the synchronous endpoint (with streaming for the operator's playground preview), then relies on the operator's pipeline to catalogue the finished episodes. For voice-clone-ready notifications, swap the TTS call for a voice_clone.ready subscription.

import { HakimClient } from '@hakim/node';

const hakim = new HakimClient({ apiKey: process.env.HAKIM_API_KEY });

// 1. Subscribe to voice_clone.ready once (idempotent · skip if already exists).
//    Most teams subscribe via the dashboard and rotate the secret out
//    of band; the API call here is for infrastructure-as-code shops.
await hakim.webhooks.create({
  url: 'https://yourapp.com/webhooks/hakim',
  events: ['voice_clone.ready', 'voice_clone.failed', 'job.succeeded', 'job.failed'],
  description: 'Production podcast pipeline',
});

// 2. Kick off a clone for the season's narrator
const { voice } = await hakim.audio.voices.clone({
  name: 'Aya, narrator',
  language: 'ar',
  consent_confirmed: true,
  file: fs.createReadStream('./aya-10s.wav'),
});
// → voice.status === 'processing'; the webhook fires when it flips to 'ready'

// 3. (In your webhook handler) · verify the signature, then render
//    each episode in the season on Faseeh v2 with the cloned voice.
export async function handleVoiceCloneReady(payload: { data: { object: { voice_id: string } } }) {
  const { voice_id } = payload.data.object;
  const episodes = await loadSeasonScripts();
  await Promise.all(
    episodes.map((script) =>
      hakim.audio.speech.create({
        model: 'hakim-v2', // Faseeh · premium narration
        input: script,
        voice: voice_id, // the cloned narrator
        response_format: 'mp3',
      }),
    ),
  );
}

The pattern stays the same for designed voices: subscribe to voice_clone.ready (the webhook fires for designed voices too because they share the Voice table), kick off the design, and render the season when the voice flips to ready.

Recipe · Verify the signature, then act idempotently

Two failure modes a production webhook handler must survive:

  1. A spoofed delivery. Always verify X-Hakim-Signature before touching any business state (see Verifying the signature above).
  2. A duplicate delivery. Our retry ladder fires up to 5 attempts; if your handler timed out after the work succeeded, attempt 2 will re-deliver the same X-Hakim-Event-Id. Idempotency is your responsibility.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { redis } from './redis';

export async function POST(req: Request) {
  const rawBody = Buffer.from(await req.arrayBuffer());
  const signature = req.headers.get('x-hakim-signature');
  const timestamp = req.headers.get('x-hakim-timestamp');
  const eventId = req.headers.get('x-hakim-event-id');

  if (!signature || !timestamp || !eventId) return new Response('bad', { status: 400 });

  // 1. Reject replays older than 5 minutes
  if (Date.now() - Number(timestamp) * 1000 > 5 * 60 * 1000) {
    return new Response('replay', { status: 400 });
  }

  // 2. Verify the HMAC
  const expected = createHmac('sha256', process.env.HAKIM_WEBHOOK_SECRET!)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');
  if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) {
    return new Response('forbidden', { status: 403 });
  }

  // 3. De-dupe · first writer wins for this event id (24h TTL)
  const isFirst = await redis.set(`webhook:${eventId}`, '1', 'EX', 86400, 'NX');
  if (!isFirst) return new Response('duplicate', { status: 200 });

  // 4. Now do the business work
  const event = JSON.parse(rawBody.toString());
  await handleEvent(event);

  return new Response('ok', { status: 200 });
}

Step 3 turns retries into no-ops without losing the first one's side-effects. The Redis key TTL matches our retry window · after 24 hours, replaying a delivery from the dashboard will run the handler fresh (which is usually what you want when manually replaying).

Recipe · Tier-aware billing reconciliation

Tier: any. Each TTS render emits a usage row tagged with the model field; webhooks don't carry per-tier breakouts for individual renders, but the invoice.finalized event lets you reconcile the month's spend against the invoice:

export async function handleInvoiceFinalized(payload: {
  data: { object: { id: string; total: number; period_end: number } };
}) {
  const { id, period_end } = payload.data.object;
  // Pull the period's usage events from your own log, broken out
  // per `model`, and compare against the invoice line items. Any
  // drift > 1% is worth investigating from the
  // dashboard's Usage → Events tab.
  const usage = await loadOwnUsage(period_end - 30 * 86400, period_end);
  const byTier = groupBy(usage, 'model');
  console.log(`Invoice ${id} reconciled`, {
    saree: byTier['hakim-fast-v1']?.length ?? 0,
    faseeh: byTier['hakim-v2']?.length ?? 0,
    mulhim: byTier['hakim-v3']?.length ?? 0,
    stt: byTier['hakim-arab-v2']?.length ?? 0,
  });
}

For finance-grade reporting, prefer the per-org Usage → Export CSV which carries the full ledger with timestamps and per-call character counts.