NomonoPlatform docs

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

EventSent when
job.completedThe job is done.
job.failedThe 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:

HeaderValue
Content-Typeapplication/json
X-Nomono-Event-IdThe event's id.
X-Nomono-Signaturet=<unix seconds>,v1=<hex>, as in t=1792143719,v1=65e5bf2b4458de975d35a93ad9d7b8c130c3f767f4235ef5e508ef5f37c4842e.

A job.completed event looks like this, formatted for reading:

job.completed
{  "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:

job.failed
{  "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  }}
FieldWhat it holds
idThe event's ID, starting evt_, which stays the same on every attempt.
typejob.completed or job.failed.
created_atWhen the event was created.
data.job_id, data.session_idThe job and its session.
data.statusThe job's status at created_at.
data.minutesThe job's minutes.
data.errorThe job's error, sent with job.failed only.
data.retry_ofThe job's retry_of.

The example signature above is for this raw body:

The 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:

  1. Read the raw body before parsing it, with request.get_data() in Flask or express.raw() in Express.
  2. Take t and v1 from the header.
  3. Compute the HMAC-SHA256 of t, . and the body with your secret, and compare its hex form with v1 in constant time.
  4. Reject the request if t is 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 "", 204

To test your code, use this secret, time and body, whose signature is 8623397cd3cd5c045a380d9f22177c9939a8f0b0353803be1cc649e6164c9d9b:

Shell
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}.