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
| Event | Fires when |
|---|---|
job.succeeded | A batch TTS / STT job completed successfully |
job.failed | A job aborted (input rejected, quota, upstream failure) |
voice_clone.ready | A voice clone finished training |
voice_clone.failed | A voice clone was rejected (consent, quality) |
invoice.finalized | A Stripe invoice transitioned to open |
invoice.paid | A Stripe invoice was paid |
invoice.payment_failed | A Stripe payment attempt failed |
subscription.updated | Plan changed (upgrade, downgrade, cancel scheduled) |
subscription.canceled | Subscription fully ended (period rollover after cancel) |
usage.hard_cap_exceeded | Org 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 asidin 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:
- A spoofed delivery. Always verify
X-Hakim-Signaturebefore touching any business state (see Verifying the signature above). - 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.