PopNowAPI Docs
Main content

Webhooks

Receive finished tasks at your own URL, verify the signature, and handle retries and duplicates.

A webhook lets PopNow tell you when a task is done, so you do not have to keep asking. There are two kinds:

Task callbackAccount endpoint
Set upcallBackUrl on each createTaskOnce, in the API console
Sent forThat one taskEvery task on the account that matches the events you chose
SignatureX-Webhook-Signature (below)Standard Webhooks headers (see below)

Most integrations only need the task callback.

1. Get your signing secret

Every delivery is signed with your account's webhook signing secret. Open the Webhooks page of the API console, generate (or rotate) the secret and store it on your server as POPNOW_WEBHOOK_SECRET. It is shown only once. Rotating takes effect immediately, including for deliveries still being retried, so update your receivers right after.

2. Pass callBackUrl

{
  "model": "alibaba/wan-3.0/image-to-video",
  "input": { "image_url": "https://media.popnow.ai/movie/lighthouse.webp" },
  "callBackUrl": "https://your-app.example.com/hooks/popnow"
}

The URL must be public HTTPS on port 443 (or no port), without a username or password, and resolve only to public addresses — checked when you create the task and again when we connect. http://localhost cannot work; use a tunnel such as Cloudflare Tunnel or ngrok while developing, or poll. An invalid URL returns 400 with msg: "Invalid callback URL".

3. What arrives

When the task reaches a final state, PopNow sends one POST:

POST /hooks/popnow HTTP/1.1
Content-Type: application/json
User-Agent: PopNow-Webhook/1
X-Webhook-Timestamp: 1787045531
X-Webhook-Signature: 9tXk3…=
X-Webhook-Payload-Version: 2

The body is exactly what recordInfo would return for the task:

{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01JH8Z4Q5W2N7R3K9M6P1C0VXD",
    "model": "alibaba/wan-3.0/image-to-video",
    "state": "succeeded",
    "output": { "assets": [{ "url": "https://…", "mime": "video/mp4", "durationSeconds": 5 }] },
    "credits": 34,
    "cost": "0.34",
    "settled": true
  },
  "request_id": "whd_01JH8Z…"
}
  • code is 200 even for a failed task: it describes the delivery, not the task. Read data.state.
  • request_id is the delivery ID. It stays the same across retries, so use it to ignore duplicates.
  • The signed links in output expire after about 20 minutes. Store the taskId, not the link.

4. Verify the signature

signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )

taskId is data.taskId from the body, timestamp is the X-Webhook-Timestamp header, and the last part is the SHA-256 of the raw request body in lowercase hex. Hash the bytes you received, not a re-serialised object: key order and whitespace would change the result.

Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.POPNOW_WEBHOOK_SECRET;

// express.raw keeps the exact bytes; express.json would throw them away.
app.post('/hooks/popnow', express.raw({ type: 'application/json', limit: '10mb' }), (req, res) => {
  const ts = Number(req.headers['x-webhook-timestamp']);
  const sig = String(req.headers['x-webhook-signature'] ?? '');
  // Refuse anything older than five minutes, however valid its signature.
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end();

  let event;
  try { event = JSON.parse(req.body.toString('utf8')); } catch { return res.status(400).end(); }
  const digest = crypto.createHash('sha256').update(req.body).digest('hex');
  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${event.data?.taskId}.${ts}.${digest}`).digest('base64');
  const ok = expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  if (!ok) return res.status(401).end();

  res.status(200).end();          // acknowledge first…
  enqueue(event.request_id, event.data); // …then do the work
});
Python (Flask)
import hashlib, hmac, base64, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["POPNOW_WEBHOOK_SECRET"].encode()

@app.post("/hooks/popnow")
def popnow_hook():
    raw = request.get_data()  # the exact bytes
    ts = request.headers.get("X-Webhook-Timestamp", "")
    sig = request.headers.get("X-Webhook-Signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(401)
    event = json.loads(raw)
    digest = hashlib.sha256(raw).hexdigest()
    message = f'{event["data"]["taskId"]}.{ts}.{digest}'.encode()
    expected = base64.b64encode(hmac.new(SECRET, message, hashlib.sha256).digest()).decode()
    if not hmac.compare_digest(expected, sig):
        abort(401)
    enqueue(event["request_id"], event["data"])  # do the slow work elsewhere
    return "", 200

X-Webhook-Payload-Version is 2 for every new integration. Version 1 is an older format kept for existing receivers; if you ever see it, the task ID is the top-level task_id.

Respond quickly

Any 2xx within 15 seconds counts as received. Anything else — another status, a timeout, a refused connection — is retried. Acknowledge first and do the slow work (downloading, transcoding, notifying users) in a background job.

Retries and duplicates

A failed delivery is retried 7 more times, after 10 s, 30 s, 2 min, 10 min, 30 min, 2 h and 6 h. Each retry is signed again with a fresh timestamp, so always check the timestamp against the current time rather than the first attempt. After the last retry the delivery is marked failed in the API console, where you can send it again by hand.

The same task can therefore arrive more than once. Make your handler idempotent: store request_id with a unique index, and when you see one again, answer 200 and do nothing.

If your server was down for longer than the retry window, list recent tasks with GET /api/v1/jobs and read any you missed with recordInfo.

Account endpoints

An account endpoint is a URL registered once in the API console together with the events it wants. It receives events for every task on the account, so you never pass callBackUrl again.

EventSent when
task.completedAny of the four below. Subscribe to this unless you need to tell them apart
task.succeededA task finished successfully
task.failedA task failed; the hold was released
task.expiredA task passed its deadline; the hold was released
task.canceledKept for old records only

Deliveries follow the Standard Webhooks format, so off-the-shelf libraries work:

webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )

Two differences from the task callback: the body itself is signed (no SHA-256 step), and the HMAC key is the Base64-decoded part of the secret after whsec_ — not the secret string. During a secret rotation the header can hold two space-separated signatures; accept the request if either matches. webhook-id stays the same across retries: use it to ignore duplicates. The event's data.object is the task record at the moment the event was created.

On this page