Webhooks
Get a signed POST when a job, batch or follow match finishes, fails, is held or is cancelled.
Pass a webhook_url with a job or a batch and we POST to it when the job or batch changes state. Starter and above:
on Free it is refused with MODE_NOT_ALLOWED (403). A follow sends its matches to its own webhook channel instead:
see Follows.
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"
}'Events
| Prop | Type | Description |
|---|---|---|
| job.completed | final | The job finished. Fetch results with GET /v1/jobs/:id. |
| job.failed | final | The job failed. Not charged. |
| job.cancelled | final | You cancelled the job, or a held job went 7 days without a top-up (HELD_EXPIRED). |
| job.held | not final | The job costs more than your balance. It resumes by itself when you add credits. |
| batch.completed | final | Every job in the batch ended. Carries counts and the aggregate answers. |
| batch.paused | not final | Nothing left to run and at least one job held. Carries credits_needed_to_resume. |
| follow.match.completed | final | A follow's match was answered. Sent only to the follow's own webhook channel. |
Jobs inside a batch send no job.* events: the batch's webhook_url gets only batch.paused and batch.completed.
Payload
{
"event": "job.completed",
"job_id": "job_abcdefghij123456",
"status": "completed",
"completed_at": "2026-04-03T12:00:45.000Z",
"credits_charged": 5
}A job that completed without a capability that could not run also carries failed_capabilities, as in its
result's meta:
"failed_capabilities": [
{
"capability": "brands",
"code": "BRAND_DETECTION_UNAVAILABLE",
"message": "Brand detection is temporarily unavailable. Please try again."
}
]{
"event": "job.failed",
"job_id": "job_abcdefghij123456",
"status": "failed",
"completed_at": "2026-04-03T12:00:45.000Z",
"credits_charged": null,
"error": {
"code": "VIDEO_TOO_LONG",
"message": "Video duration 1830s exceeds plan limit of 600s"
}
}error.code is a job failure code.
{
"event": "job.cancelled",
"job_id": "job_abcdefghij123456",
"status": "cancelled",
"completed_at": "2026-04-03T12:00:20.000Z",
"credits_charged": 1
}credits_charged is the work a running job had done, or null. A held job that went 7 days without a top-up also
carries "error": { "code": "HELD_EXPIRED", "message": "…" }.
{
"event": "job.held",
"job_id": "job_abcdefghij123456",
"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.completed",
"batch_id": "batch_abcdefghij123456",
"status": "completed",
"total_jobs": 2,
"completed_count": 2,
"failed_count": 0,
"credits_charged": 14,
"aggregate_results": {
"aggregate_answers": [
{
"query": "Which of these videos shows animals?",
"answer": "Only \"Me at the zoo\" does: elephants walk behind the speaker.",
"video_references": ["https://www.youtube.com/watch?v=jNQXAC9IVRw"]
}
],
"cross_video_summary": "Two short clips; one is filmed at a zoo."
},
"completed_at": "2026-04-03T12:04:10.000Z"
}status is failed when no job completed; credits_charged is then 0 and aggregate_results null. Each
answer's video_references lists the videos it rests on (an upload's upload_key). A cancelled batch sends no
batch.completed.
{
"event": "batch.paused",
"batch_id": "batch_abcdefghij123456",
"status": "paused",
"rollup": { "total": 10, "pending": 0, "processing": 0, "completed": 4, "held": 6, "failed": 0, "cancelled": 0 },
"credits_needed_to_resume": 48
}credits_needed_to_resume is what a top-up must add to resume every held job.
{
"event": "follow.match.completed",
"follow_id": "fol_abc123",
"creator": {
"platform": "youtube",
"handle": "@examplechannel",
"name": "Example Channel",
"url": "https://www.youtube.com/@examplechannel"
},
"rule_summary": "\"marathon training\"",
"video": {
"id": "dQw4w9WgXcQ",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"title": "My first marathon",
"thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
"published_at": "2026-04-03T11:52:10.000Z"
},
"match_reason": "Matched your filters",
"job_id": "job_abcdefghij123456",
"credits_charged": 1,
"question": "Which race was it, and what was the finish time?",
"answer": "The Berlin Marathon, finished in 3:58:12.",
"excerpt": "Today I ran my first marathon…",
"dashboard_url": "https://netraflow.com/your-workspace/jobs/job_abcdefghij123456",
"completed_at": "2026-04-03T12:00:45.000Z"
}| Prop | Type | Description |
|---|---|---|
| follow_id | string | The follow that matched the video. |
| creator | { platform, handle, name, url } | The followed creator. platform is youtube or tiktok; name is null when the platform gave none. |
| rule_summary | string | The rule in short: its description if it has one, else its filters, such as #ad · "marathon". |
| video | { id, url, title, thumbnail_url, published_at } | The video that matched. title, thumbnail_url and published_at can be null. |
| match_reason | string | null | One line on why the video matched the rule. |
| job_id | string | The job that analyzed it. GET /v1/jobs/:id returns its results. |
| credits_charged | integer | null | What the job was charged, null when nothing was billed. |
| question | string | The follow's question: what every matched video is asked. |
| answer | string | null | The answer, without timestamps. null when none could be written. |
| excerpt | string | null | The start of the summary, or of the transcript. Up to 400 characters. |
| dashboard_url | string | The answer in the console. |
| completed_at | string (ISO 8601) | When the job completed. |
| Prop | Type | Description |
|---|---|---|
| event | string | One of the event types listed above. |
| job_id | string | Job identifier. Present on job.* events. |
| batch_id | string (optional) | Batch identifier. Present on batch.* events only. |
| 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 charged. Always present on job events, null when nothing was billed. On batch.completed, the completed jobs plus the aggregate answers. |
| error | { code: string, message: string } (optional) | A job failure code and a message to show a person. On job.failed, and on job.cancelled for HELD_EXPIRED. |
| failed_capabilities | { capability: string, code: string, message: string }[] (optional) | What a completed job could not run. job.completed only, when one failed. Not charged. |
credits_charged can be null, and new event types can be added. Don't type event as a closed union or
credits_charged as a non-null integer.
Signature verification
Every workspace has one signing secret, prefixed whsec_. Find it in the console under Settings → Webhook secret
(owners and managers). The API never returns it.
Every delivery carries:
X-NetraFlow-Signature-V2: t=1767225600,v1=f1df05f2f0707f7db9552d77516018f74b9d50be3ebe36a9c492c349cc324be1
User-Agent: NetraFlow-Webhooks/1.0
Content-Type: application/jsont is when the delivery was first sent, in Unix seconds; retries repeat it. Each v1 is a hex HMAC-SHA256 of
<t>.<raw body> keyed with your secret. To verify:
- Read the raw body before parsing it.
- Reject the delivery if
tis more than 5 minutes from your clock. - Compute the HMAC and compare it with each
v1in constant time. Any match is genuine.
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
function verifyWebhook(body, header, secret, now = Date.now()) {
if (typeof header !== 'string') return false;
const pairs = header.split(',').map((part) => part.split('='));
const timestamp = pairs.find(([key]) => key === 't')?.[1];
const sentAt = Number(timestamp);
if (!Number.isFinite(sentAt) || Math.abs(now / 1000 - sentAt) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'));
return pairs.some(([key, value]) => {
if (key !== 'v1' || !value) return false;
const given = Buffer.from(value);
// 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 given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
// In your request handler:
const body = await request.text();
const header = request.headers.get('X-NetraFlow-Signature-V2');
if (!verifyWebhook(body, header, WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 });
}
// Signature valid and recent — process the event
const event = JSON.parse(body);import hashlib
import hmac
import json
import time
TOLERANCE_SECONDS = 300
def verify_webhook(body: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
pairs = [part.split("=", 1) for part in header.split(",")]
timestamp = next((pair[1] for pair in pairs if len(pair) == 2 and pair[0] == "t"), "")
if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
return any(
len(pair) == 2 and pair[0] == "v1" and hmac.compare_digest(pair[1].encode(), expected)
for pair in pairs
)
# In a Flask view:
body = request.get_data()
if not verify_webhook(body, request.headers.get("X-NetraFlow-Signature-V2"), WEBHOOK_SECRET):
abort(401)
event = json.loads(body)Rotating the secret
Rotate in Settings → Webhook secret. For 24 hours the old secret signs every delivery too (a second v1), so
switch your receiver to the new one within that window. Rotating again inside the 24 hours drops the oldest secret at
once.
Duplicates
The same delivery can reach you twice, for example when your server answers after our timeout. A retry repeats the body
and signature byte for byte, so drop a repeat you accepted in the last 5 minutes. job.held and batch.paused can come
again after a top-up: treat each as the latest state. When in doubt, read the job or batch from the API.
Delivery
- Answer fast. Return any 2xx within 5 seconds, then do the work. Anything else, a timeout or a 3xx (redirects aren't followed), is a failed attempt.
- Retries. After the first attempt, 3 more: after 1 second, 10 seconds and 60 seconds. Then the delivery is dropped.
- URL rules.
https://only, on a public address:localhost, private, link-local and cloud-metadata addresses, names that resolve to them, and IPv6 literals are refused withINVALID_URL.