Webhooks
Get notified at your HTTPS endpoint when a task succeeds or fails.
Set webhook_url when you create a task and NanoAPI sends it a POST once the task has finished, whether it succeeded or failed. Tasks without a webhook_url send no webhooks.
Webhooks follow Standard Webhooks, so its official libraries can verify signatures. Verification is optional.
Events
| Event | Sent when |
|---|---|
task.succeeded | The task succeeds. |
task.failed | The task fails, including a queue timeout. |
Each task sends exactly one of these events. There's no event when generation starts: if you need to show progress, read status with GET /v1/tasks/{id}.
Request format
POST <webhook_url>
Content-Type: application/json
webhook-id: ev_01jb9a3k7m2n5p8q4r6s1t0v9w
webhook-timestamp: 1792658832
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
User-Agent: Nanofield-Webhooks/1.0{
"type": "task.succeeded",
"timestamp": "2026-10-22T08:47:12Z",
"data": {
"object": "task",
"id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p",
"status": "succeeded",
"outputs": [{ "index": 0, "type": "video", "url": "https://file.example.com/…/0.mp4", "preview_url": "https://file.example.com/…/0_compressed.mp4" }],
"billing": { "currency": "USD", "frozen": "4.095", "charged": "4.095", "refunded": "0.00", "billed_seconds": 15 },
"metadata": { "order_id": "A1001" }
}
}| Field or header | Description |
|---|---|
type | Event type. |
timestamp | When the event happened (ISO 8601). |
data | The full task object (abbreviated above) as GET /v1/tasks/{id} returned it at that moment. You usually don't need to fetch it again. |
webhook-id | Event ID. Unchanged across retries, as is the body. Deduplicate on it. |
webhook-timestamp | When this delivery attempt was sent (Unix seconds). |
webhook-signature | See Verify signatures. |
Delivery and retries
- A
2xxwithin 15 seconds counts as delivered. Anything else counts as undelivered: other statuses (including3xx; redirects aren't followed), timeouts and connection errors. - An undelivered event is retried 3 times, after 1 minute, 5 minutes and 30 minutes (4 attempts at most), then NanoAPI gives up. The task is still available from
GET /v1/tasks/{id}. - An event can arrive more than once, for example when your
2xxdidn't reach us in time. The task's final status never changes.
Handling an event
- Optionally verify the signature.
- Respond
2xxright away; process afterwards, for example in a background job. - Skip any
webhook-idyou've already processed. - On
task.succeeded, downloaddata.outputs. Ontask.failed, readdata.error.code; the frozen amount is already back in your balance.
Fallback: if no event has arrived 60 minutes after the task was created, for example because all attempts failed, fetch it once with GET /v1/tasks/{id}.
URL requirements
httpsonly, at most 2048 characters.- No private, loopback or reserved addresses. The URL is checked when the task is created, and the hostname is resolved before every delivery attempt: if any resolved address isn't public, that attempt isn't sent and counts as undelivered.
Verify signatures (optional)
Every webhook is signed. Without verification, anyone who knows your webhook URL can send fake events, so keep it private. Verify when an event triggers something important, such as delivering or charging for an order.
Signing secret
- Starts with
whsec_. View and reset it in Console → Webhooks. - One secret per user, signing the webhooks of all your tasks.
- After a reset the old secret stays valid for 24 hours, and every webhook in that window carries two signatures, one per secret. Deploy the new secret, then retire the old one. Resetting again within the 24 hours revokes the oldest secret immediately.
How signatures are computed
- Signed content:
{webhook-id}.{webhook-timestamp}.{raw request body}: the two headers and the raw body, joined with periods. - Key: the part of the secret after
whsec_, base64-decoded. - Algorithm: HMAC-SHA256 of the signed content, base64-encoded.
- Header:
webhook-signaturecan hold several space-separated signatures (v1,xxx v1,yyyduring a rotation). The request is valid if anyv1signature matches. Compare in constant time. - Replay protection: reject requests whose
webhook-timestampis more than 5 minutes (300 seconds) from the current time.
Verify the raw request body exactly as received. Don't parse and re-serialize the JSON: the bytes can differ and the signature won't match.
Standard library only (Python 3.9+).
import base64, hashlib, hmac, time
def verify_webhook(secret: str, headers: dict, body: bytes) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for item in headers["webhook-signature"].split(" "):
version, _, signature = item.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return FalseIn Flask (request.headers is case-insensitive; request.get_data() is the raw body):
import os
from flask import Flask, request
from verify_webhook import verify_webhook
app = Flask(__name__)
SECRET = os.environ["NANOAPI_WEBHOOK_SECRET"]
@app.post("/hooks/nanoapi")
def nanoapi_webhook():
if not verify_webhook(SECRET, request.headers, request.get_data()):
return "", 401
event = request.get_json()
# Queue the event for background processing, deduplicated on webhook-id.
print(request.headers["webhook-id"], event["type"], event["data"]["status"])
return "", 204