Concepts
Webhooks
When a job finishes, we send a signed POST request to your server.
Endpoints
To add an endpoint, send us its URL and the events it should receive. We'll give you the endpoint's signing secret, which starts with whsec_ and is shown only once, so store it as carefully as an API key. You can have several endpoints, each with its own events.
Events
| Event | Sent when |
|---|---|
job.completed | The job is done. |
job.failed | The job is failed. |
A cancelled job doesn't send an event.
The request
Each event is a POST to the endpoint's URL with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Nomono-Event-Id | The event's id. |
X-Nomono-Signature | t=<unix seconds>,v1=<hex>, as in t=1792143719,v1=65e5bf2b4458de975d35a93ad9d7b8c130c3f767f4235ef5e508ef5f37c4842e. |
A job.completed event looks like this, formatted for reading:
{ "id": "evt_01a144729c517e08b6d23a4f1c8e9b05", "type": "job.completed", "created_at": "2026-10-16T09:41:59.012870Z", "data": { "job_id": "job_01a14471b2d47f19b6a3c0e5d8f9a1b2", "session_id": "ses_01a14471a03b7c56a1e28d9f4b6c7d8e", "status": "done", "minutes": 30.57, "retry_of": null }}A job.failed event also has error:
{ "id": "evt_01a14474d07e7f3a9c1b5e2d8f6a0c4e", "type": "job.failed", "created_at": "2026-10-16T09:29:12.671033Z", "data": { "job_id": "job_01a14471b3127a8c9e4d2f6b1a0c3e5d", "session_id": "ses_01a14471a03b7c56a1e28d9f4b6c7d8e", "status": "failed", "minutes": 30.57, "error": { "code": "processing_failed", "message": "Processing failed. Create the job again; this is safe. Send a new Idempotency-Key if you used one." }, "retry_of": null }}| Field | What it holds |
|---|---|
id | The event's ID, starting evt_, which stays the same on every attempt. |
type | job.completed or job.failed. |
created_at | When the event was created. |
data.job_id, data.session_id | The job and its session. |
data.status | The job's status at created_at. |
data.minutes | The job's minutes. |
data.error | The job's error, sent with job.failed only. |
data.retry_of | The job's retry_of. |
The example signature above is for this raw body:
{"id":"evt_01a144729c517e08b6d23a4f1c8e9b05","type":"job.completed","created_at":"2026-10-16T09:41:59.012870Z","data":{"job_id":"job_01a14471b2d47f19b6a3c0e5d8f9a1b2","session_id":"ses_01a14471a03b7c56a1e28d9f4b6c7d8e","status":"done","minutes":30.57,"retry_of":null}}Verifying the signature
In X-Nomono-Signature, t is the Unix time of the attempt and v1 is the lowercase hex HMAC-SHA256 of {t}.{raw body}, keyed with the whole secret, whsec_ included.
To verify a request:
- Read the raw body before parsing it, with
request.get_data()in Flask orexpress.raw()in Express. - Take
tandv1from the header. - Compute the HMAC-SHA256 of
t,.and the body with your secret, and compare its hex form withv1in constant time. - Reject the request if
tis more than five minutes from your clock.
These receivers for Flask and Express read the secret from NOMONO_WEBHOOK_SECRET:
import hashlibimport hmacimport jsonimport osimport timefrom flask import Flask, abort, requestWEBHOOK_SECRET = os.environ["NOMONO_WEBHOOK_SECRET"]TOLERANCE_SECONDS = 5 * 60app = Flask(__name__)def verify_signature(secret: str, header: str, body: bytes) -> bool: timestamp = "" signature = "" for field in header.split(","): name, _, value = field.partition("=") if name == "t": timestamp = value elif name == "v1": signature = value if not timestamp.isdecimal() or not signature: return False if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False signed = timestamp.encode() + b"." + body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), signature.encode())@app.post("/nomono")def receive_event(): body = request.get_data() header = request.headers.get("X-Nomono-Signature", "") if not verify_signature(WEBHOOK_SECRET, header, body): abort(400) event = json.loads(body) # Queue the event and return right away. print(event["id"], event["type"], event["data"]["job_id"]) return "", 204To test your code, use this secret, time and body, whose signature is 8623397cd3cd5c045a380d9f22177c9939a8f0b0353803be1cc649e6164c9d9b:
printf '%s' '1759579200.{"id":"evt_0199a3b2c4d57e8f9a0b1c2d3e4f5a6d","type":"job.completed"}' \ | openssl dgst -sha256 -hmac 'whsec_00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'The example under The request is signed with the same secret. Skip the time check when you test with either one, since both timestamps are in the past.
Responding
Your endpoint has to return a 2xx within 10 seconds. Anything else, including a redirect or a slower response, counts as a failed attempt and we send the event again, so queue the work and respond right away.
Retries
We make up to six attempts, trying again 1, 5, 30, 120 and then 360 minutes after each failed one, so the last comes about 8.5 hours after the first. Every attempt sends the same body and event ID with a new t and signature.
Duplicates and order
You can get the same event more than once, so store the id of each event you handle and return a 2xx for a repeat without handling it again.
Events can also arrive out of order, and a job that timed out can send job.completed after job.failed. For each job, act on the event with the latest created_at, or read the job itself with GET /v1/jobs/{job_id}.