Media task callbacks

Optional signed webhook for completed, failed, and cancelled media tasks.

Create a media task with callback_url when you want HopsAPI to POST the terminal result to your server. Polling GET /v1/router/tasks/{task_id} stays available. If the webhook never arrives, or every attempt fails, use polling as the fallback.

Turn it on

callback_url is optional on POST /v1/router/task/create. Omit it and the task behaves as before: nothing is pushed.

curl https://hopsapi.com/v1/router/task/create \
  -H "Authorization: Bearer sk-YOUR_KEY" \
  -H "content-type: application/json" \
  -d '{
    "model": "grok-imagine-video",
    "task": "Txt2Video",
    "mode": "fast",
    "callback_url": "https://example.com/hooks/media",
    "input": { "prompt": "a cat walking", "duration": 5, "resolution": "720p", "aspect_ratio": "16:9" }
  }'
{ "code": 200, "message": "success", "task_id": "tsk_0f3c9a72b1e04d5f" }

The address must be:

  • public HTTPS (HTTP is accepted only when you call a local deployment)
  • at most 2048 characters
  • without a username or password
  • not a loopback, private, link-local, or metadata address, including localhost

Anything else is rejected before a task is created and before any charge:

{
  "error": {
    "message": "callback_url must be a public https URL",
    "type": "invalid_request_error",
    "code": "invalid_callback_url"
  }
}

When it is sent

HopsAPI sends the webhook after the task reaches a terminal status and billing is finished:

StatusMeaning
2Completed. cost in the body is the final charge.
3Failed. No charge is settled.
4Cancelled. No charge is settled.

A task is delivered once. Later duplicate updates do not send another successful webhook.

The callback request

POST https://example.com/hooks/media
Content-Type: application/json
User-Agent: Media-Webhook/1
webhook-id: tsk_0f3c9a72b1e04d5f
webhook-timestamp: 1760083200
webhook-signature: v1,example-signature
HeaderMeaning
Content-Typeapplication/json
User-AgentMedia-Webhook/1
webhook-idThe platform task_id. Use it as the idempotency key.
webhook-timestampUnix time in seconds when this attempt was signed.
webhook-signaturev1, plus the base64 HMAC described below.

The header names follow Standard Webhooks. The body is the same JSON GET /v1/router/tasks/{task_id} would return at that moment, including your price and excluding upstream cost.

Completed:

{
  "task_id": "tsk_0f3c9a72b1e04d5f",
  "status": 2,
  "results": [{ "urls": ["https://cdn.example/video.mp4"] }],
  "cost": 1.25
}

Failed:

{
  "code": 424,
  "message": "Provider error (request id: aitk-0123456789abcdef01234567)",
  "task_id": "tsk_0f3c9a72b1e04d5f",
  "status": 3,
  "fail_reason": "Provider error (request id: aitk-0123456789abcdef01234567)",
  "error_message": "Provider error (request id: aitk-0123456789abcdef01234567)"
}

Cancelled:

{
  "task_id": "tsk_0f3c9a72b1e04d5f",
  "status": 4,
  "message": "cancelled"
}

Field sets vary by model. Trust the task query response, not a fixed schema beyond task_id and status.

Verify the signature

The signature is derived from the API key that created the task. Replacing or deleting that key later does not change verification for a task that was already created. You must keep the original key.

  1. Derive signing_key = HMAC-SHA256(key = api_key, message = "media-webhook-v1"). The result is raw bytes, not hex.
  2. Concatenate webhook-id, ., webhook-timestamp, ., and the raw body bytes.
  3. Compute base64(HMAC-SHA256(key = signing_key, message = that string)).
  4. Compare it, in constant time, with the value after v1, in webhook-signature.

Use the raw request bytes. Parsing JSON and serializing it again changes the signature. Reject timestamps more than 5 minutes away from your clock so a captured request cannot be replayed. Verification is optional. Without it, you cannot tell that the request came from HopsAPI.

Node.js (Express). Install express, then run the file with node:

import crypto from 'node:crypto';
import express from 'express';

const apiKey = process.env.API_KEY;
if (!apiKey) throw new Error('Set API_KEY to the key that created the task');

const app = express();
app.post('/hooks/media', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
  const id = req.get('webhook-id') ?? '';
  const timestamp = req.get('webhook-timestamp') ?? '';
  const header = req.get('webhook-signature') ?? '';
  const timestampSec = Number(timestamp);
  const fresh =
    Number.isInteger(timestampSec) && Math.abs(Math.floor(Date.now() / 1000) - timestampSec) <= 300;
  const signingKey = crypto.createHmac('sha256', apiKey).update('media-webhook-v1').digest();
  const expected = crypto
    .createHmac('sha256', signingKey)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest('base64');
  const presented = header.startsWith('v1,') ? header.slice(3) : '';
  const left = Buffer.from(expected);
  const right = Buffer.from(presented);
  const signed = fresh && left.length === right.length && crypto.timingSafeEqual(left, right);
  if (!signed) {
    res.status(401).send('invalid signature');
    return;
  }
  res.status(200).send('ok');
  queueMicrotask(() => {
    const task = JSON.parse(rawBody);
    console.log(task.task_id, task.status);
  });
});

app.listen(8080);

Python (Flask and FastAPI). Sign over request.get_data() or await request.body(), not a re-serialized dict.

import base64, hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)
API_KEY = os.environ["API_KEY"]

def signed(raw: bytes) -> bool:
    timestamp = request.headers.get("webhook-timestamp", "")
    header = request.headers.get("webhook-signature", "")
    try:
        timestamp_sec = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - timestamp_sec) > 300 or not header.startswith("v1,"):
        return False
    signing_key = hmac.new(API_KEY.encode(), b"media-webhook-v1", hashlib.sha256).digest()
    message = b".".join([request.headers.get("webhook-id", "").encode(), timestamp.encode(), raw])
    expected = base64.b64encode(hmac.new(signing_key, message, hashlib.sha256).digest()).decode()
    return hmac.compare_digest(expected, header[3:])

@app.post("/hooks/media")
def hook():
    raw = request.get_data()
    if not signed(raw):
        return "invalid signature", 401
    return "ok", 200
import base64, hashlib, hmac, os, time
from fastapi import FastAPI, Request
from fastapi.responses import Response

app = FastAPI()
API_KEY = os.environ["API_KEY"]

@app.post("/hooks/media")
async def hook(request: Request):
    raw = await request.body()
    timestamp = request.headers.get("webhook-timestamp", "")
    header = request.headers.get("webhook-signature", "")
    webhook_id = request.headers.get("webhook-id", "")
    try:
        timestamp_sec = int(timestamp)
    except ValueError:
        return Response(status_code=401)
    if abs(int(time.time()) - timestamp_sec) > 300 or not header.startswith("v1,"):
        return Response(status_code=401)
    signing_key = hmac.new(API_KEY.encode(), b"media-webhook-v1", hashlib.sha256).digest()
    message = b".".join([webhook_id.encode(), timestamp.encode(), raw])
    expected = base64.b64encode(hmac.new(signing_key, message, hashlib.sha256).digest()).decode()
    if not hmac.compare_digest(expected, header[3:]):
        return Response(status_code=401)
    return Response(status_code=200)

Fixed test vector. The body is exactly 58 bytes, with no extra spaces or newline:

FieldValue
API keysk-test-webhook-vector
webhook-idtsk_01HZTESTVECTOR
webhook-timestamp1760083200
raw body{"task_id":"tsk_01HZTESTVECTOR","status":2,"cost":1.25}
webhook-signaturev1,nnc9LrWIygoIE5sp4ICtRpE20YrQa+Ij7/PeU9QSTAk=

How to respond

Return any HTTP 2xx within 10 seconds. The response body is ignored. Return 2xx before you do slow work. A 3xx is not followed and counts as a failure.

Retries

A delivery fails on a non-2xx status, a timeout, or a connection error. HopsAPI retries after 1 minute, 5 minutes, and 30 minutes. After 4 failed attempts it stops. Poll the task if you still need the result.

Each retry uses a new timestamp and a new signature. Treat webhook-id as the idempotency key so a retry does not apply the same task twice.

If nothing arrives

  • The URL is public HTTPS and does not point at a private network.
  • Your server answers with 2xx in under 10 seconds.
  • A firewall is not dropping the POST.
  • GET /v1/router/tasks/{task_id} already shows status 2, 3, or 4. If it is still 0 or 1, the webhook has not been sent yet.
On this page

On this page