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:
| Status | Meaning |
|---|---|
2 | Completed. cost in the body is the final charge. |
3 | Failed. No charge is settled. |
4 | Cancelled. 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| Header | Meaning |
|---|---|
Content-Type | application/json |
User-Agent | Media-Webhook/1 |
webhook-id | The platform task_id. Use it as the idempotency key. |
webhook-timestamp | Unix time in seconds when this attempt was signed. |
webhook-signature | v1, 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.
- Derive
signing_key = HMAC-SHA256(key = api_key, message = "media-webhook-v1"). The result is raw bytes, not hex. - Concatenate
webhook-id,.,webhook-timestamp,., and the raw body bytes. - Compute
base64(HMAC-SHA256(key = signing_key, message = that string)). - Compare it, in constant time, with the value after
v1,inwebhook-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", 200import 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:
| Field | Value |
|---|---|
| API key | sk-test-webhook-vector |
webhook-id | tsk_01HZTESTVECTOR |
webhook-timestamp | 1760083200 |
| raw body | {"task_id":"tsk_01HZTESTVECTOR","status":2,"cost":1.25} |
webhook-signature | v1,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
2xxin under 10 seconds. - A firewall is not dropping the POST.
GET /v1/router/tasks/{task_id}already shows status2,3, or4. If it is still0or1, the webhook has not been sent yet.