Webhooks
Receive real-time notifications when jobs complete, fail, or are cancelled.
Webhooks deliver HTTP POST notifications to your server when a job or batch changes state. Available on Starter, Pro, and Scale — a webhook_url on a Free-plan request is rejected with MODE_NOT_ALLOWED (403).
Events
| Prop | Type | Description |
|---|---|---|
| job.completed | terminal | Job finished successfully. Results are available via GET /v1/jobs/:id. |
| job.failed | terminal | Job failed at any pipeline stage. Failed jobs are not charged. |
| job.cancelled | terminal | A job that had already started processing was cancelled. Cancelling a job that is still pending does not emit this event, and neither does a held job expiring — poll GET /v1/jobs/:id to confirm those. |
| job.held | non-terminal | The job passed the submit check, but its real cost exceeds your balance. It parks until you add credits, then resumes automatically. Carries credits_needed and balance. |
| batch.completed | terminal | Every job in the batch reached a terminal state. Carries per-status counts and aggregate results. |
| batch.paused | non-terminal | No runnable jobs remain and at least one is held. Carries credits_needed_to_resume. |
Payload
{
"event": "job.completed",
"job_id": "job_abc123",
"status": "completed",
"completed_at": "2026-04-03T12:00:45.000Z",
"credits_charged": 5
}{
"event": "job.held",
"job_id": "job_abc123",
"status": "held",
"credits_charged": null,
"reason": "insufficient_credits",
"credits_needed": 26,
"balance": 4
}Held is not terminal, so there is no completed_at.
{
"event": "batch.paused",
"batch_id": "bat_abc123",
"status": "paused",
"rollup": { "completed": 4, "failed": 0, "held": 6, "pending": 0 },
"credits_needed_to_resume": 48
}batch.completed carries total_jobs, completed_count, failed_count, credits_charged, aggregate_results, and completed_at.
| Prop | Type | Description |
|---|---|---|
| event | string | One of the six event types listed above. |
| job_id | string | Job identifier. Present on job.* events. |
| batch_id | string (optional) | Batch identifier. Present on batch.* events, and on job.* events for jobs that belong to a batch. |
| status | string | The status the job or batch moved into. |
| completed_at | string (ISO 8601, optional) | When a terminal state was reached. Absent on job.held and batch.paused. |
| credits_charged | integer | null | Credits consumed. Always present on job events, but null when nothing was billed — job.failed, job.held, and cancels that spent no metered compute. |
credits_charged is nullable, and events other than the three job-terminal ones are real. A consumer that types
event as a closed three-value union, or credits_charged as a non-null integer, will reject valid live deliveries.
Setting up webhooks
Pass a webhook_url when you create a job or a batch. The URL is per-request — there is no account-level endpoint to register.
curl -X POST https://api.netraflow.com/v1/jobs \
-H "Content-Type: application/json" \
-H "X-Api-Key: sk_live_your_key_here" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"capabilities": ["transcription"],
"webhook_url": "https://your-server.com/webhooks/netraflow"
}'Return 200 OK immediately before processing the payload. The webhook timeout is 5 seconds — if your server does
heavy processing synchronously, you risk unnecessary retries. Acknowledge first, process async.
Your webhook secret
Every workspace has one signing secret, prefixed whsec_. It is created with the workspace — there is nothing to enable.
- Dashboard — Settings → Webhook secret → Reveal secret. The same card rotates it.
- API —
GET /v1/accountreturns it aswebhook_secret.
Rotating takes effect immediately and invalidates the previous secret, so deploy the new value to your receiver before rotating.
Signature verification
Webhook requests include an X-NetraFlow-Signature header containing an HMAC-SHA256 signature of the raw JSON body, signed with your webhook secret.
import crypto from 'node:crypto';
function verifyWebhook(body, signature, secret) {
if (typeof signature !== 'string') return false;
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
const a = Buffer.from(signature, 'utf8');
const b = Buffer.from(expected, 'utf8');
// Length check first: timingSafeEqual throws on a length mismatch, which would
// turn a malformed request into a 500 instead of the 401 we want.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// In your request handler:
const body = await request.text();
const signature = request.headers.get('X-NetraFlow-Signature');
if (!verifyWebhook(body, signature, WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 });
}
// Signature valid — process the event
const event = JSON.parse(body);Retry strategy
Failed deliveries are retried with exponential backoff:
| Prop | Type | Description |
|---|---|---|
| Attempt 1 | Immediate | First delivery attempt, no delay. |
| Attempt 2 | 1 second | First retry after 1 second. |
| Attempt 3 | 10 seconds | Second retry after 10 seconds. |
| Attempt 4 | 60 seconds | Final retry after 60 seconds. |
Each attempt has a 5-second timeout. A delivery is successful when your server returns any 2xx status code.
Requirements
- HTTPS only — HTTP URLs are rejected at job creation time.
- Private and reserved addresses blocked — URLs resolving to loopback, private, link-local, or cloud-metadata addresses are rejected to prevent SSRF. IPv6 literal hosts are rejected outright; use a domain name.