Skip to content

Webhook Events

OmnAPI emits webhooks for two layers of task lifecycle:

  • Task-level: task.completed, task.failed, task.cancelled — fired exactly once per task at terminal state. Backwards-compatible with the v1 event set.
  • Stage-level: task.stage.started, task.stage.completed, task.stage.failed — fired for every named stage in long-running tasks (currently MV storyboard + finalize). Lets you build progress bars or observability dashboards without polling.

Both layers fire to the same destination URL, marked by the event field in the JSON envelope.


Pass webhookUrl in the task creation request:

Terminal window
curl -X POST https://api.omnapi.com/api/v1/suno/songs \
-H "x-api-key: om_live_..." \
-d '{
"mode": "simple",
"prompt": "upbeat lo-fi",
"config": {
"webhookUrl": "https://yourapp.com/webhooks/omnapi"
}
}'

The dashboard also lets you set a default destination for account-level task events. Per-task webhookUrl takes precedence for that one request.


Every event uses this outer envelope:

{
"event": "task.completed",
"eventId": "evt_01H...",
"deliveredAt": "2026-05-30T14:23:11.482Z",
"task": {
"id": "task_01H...",
"userId": "usr_...",
"modelCode": "mv-storyboard",
"featureCode": "generate-storyboard",
"status": "COMPLETED",
"creditsRequired": 250,
"creditsCharged": 250,
"createdAt": "...",
"completedAt": "...",
"durationMs": 187432,
"inputParams": { /* original POST body */ },
"outputResults": { /* final task output */ },
"metadata": { "appSlug": "mv-maker" }
}
}

task is the same shape returned by GET /api/v1/tasks/:id. Stage events add an inner stage block (see below).


Fired once when a task transitions to COMPLETED. task.outputResults carries the final payload, creditsCharged reflects the actual cost (may be less than creditsRequired if the generated output costs less than the reserved amount).

Fired once on FAILED. The envelope adds:

{
"task": {
"errorCode": "MV_TOO_MANY_FAILED_SCENES",
"errorMessage": "5/8 scene images failed (threshold 0.3)"
}
}

A refund (back to creditAccount.balance) is issued atomically with the status transition — by the time you receive the event, the refund is already settled.

Fired when a task is cancelled before it completes. Same envelope as task.failed minus errorCode/errorMessage. Refund also atomic.


Long-running tasks (currently MV storyboard generation + MV finalize) break their work into NAMED stages tracked by TaskStageTracker. Each stage emits three events: startedcompleted (success path) or startedfailed (failure path).

{
"event": "task.stage.completed",
"eventId": "evt_01H...",
"deliveredAt": "...",
"task": { /* same shape as above */ },
"stage": {
"name": "scenes",
"description": "Plan scenes",
"startedAt": "...",
"completedAt": "...",
"durationMs": 47213,
"attemptCount": 1,
"maxAttempts": 2,
"payload": {
"sceneCount": 8,
"genre": "rap",
"mvParadigm": "performance"
}
}
}

stage.payload is stage-specific — each adapter populates it via ctx.setPayload({...}). Don’t assume any fixed schema; treat unknown keys as metadata.

When you submit POST /api/v1/mv with mode: "studio", you’ll see this sequence:

#Stage nameTypical duration
1resolve-song<1s (cache hit) or 5-15s (Suno fetch)
2analyze<1s (pure CPU)
3emotion5-30s
4concept5-30s
5character-anchor15-40s (only when concept.needsCharacterAnchor=true)
6narrative5-30s
7scenes10-60s
8scene-images60-240s (N parallel image gens, ~25s each p99)

Followed by the task-level task.completed. Total wall-clock 3-10min for a typical 60-second song.

The render task is simpler — single render submit + poll. No stage events emitted (the entire task IS one stage). You get task.completed directly.

Currently single-stage — task.completed directly, no task.stage.*.

Stage failures are RETRYABLE per the stage’s maxAttempts budget. You receive task.stage.failed per attempt that bombs:

{
"event": "task.stage.failed",
"stage": {
"name": "scenes",
"attemptCount": 1,
"maxAttempts": 2,
"errorCode": "LLM_TIMEOUT",
"errorMessage": "stage timed out after 120s"
}
}

Then either:

  • The next attempt produces task.stage.completed → pipeline continues
  • All attempts fail → the WHOLE TASK fails → you also receive task.failed

A stage failure does NOT necessarily mean the task failed — wait for the task-level event to know the final outcome.


GuaranteeStrength
Within a task, task.stage.started precedes the corresponding task.stage.completed/failedStrong — they’re sent in order from the same worker.
Stages within a task are delivered in execution orderStrong — workers fire events synchronously from the stage tracker.
Across tasks: events arrive in any orderNo guarantee — we don’t serialize across users / tasks.
Exactly-once deliveryAt-least-once — retries can re-deliver. Dedupe by eventId.
Delivery latency<5s p99 from event creation; <60s including retry attempts.

We retry failed webhook deliveries up to 5 times with exponential backoff (30s → 1m → 5m → 15m → 1h). HTTP 2xx is treated as success. After 5 failures the event is marked FAILED and visible in the dashboard’s Webhook Events page where you can manually re-trigger.


Treat the destination URL as a secret endpoint and restrict it to your own domain. Store eventId and reject duplicates in your application.


  1. Acknowledge fast. Respond 2xx within 30s, then process async. Slow webhooks consume worker connection pool.
  2. Dedupe by eventId. Retries can re-deliver any event.
  3. Don’t depend on stage events for billing logic — billing settles at task-level. Use stage events ONLY for UX / observability.
  4. Subscribe to task.stage.completed, not task.stage.started unless you’re building a live progress bar. Started events are noisy.
  5. Capture eventId + deliveredAt in your observability stack — useful for debugging missed events.

  • Async Jobs — task lifecycle + status enum
  • MV API — the canonical multi-stage flow
  • Dashboard → Webhooks — see delivery history + manually retry failed events