Callbacks and signatures
There are three ways to get responses.
| Polling | GET /v1/messages/{id}. Works with no public endpoint |
|---|---|
| callback | A POST to the URL you register |
| Events API | GET /v1/actions/events with a read key |
Registering a URL
In the channel details in the app, open Replies to, enter the URL, and register it with Face ID. Managers, Owners, and Admins can do this. The URL must meet these conditions.
- https on port 443, up to 512 characters
- A named host that resolves to public addresses. IP addresses and URLs with credentials are refused
Registering sends this body, unsigned.
{"type":"verification","challenge":"chl_..."}
Only a URL that returns the challenge value in a 2xx body is registered. Otherwise you get callback_unverified.
On success the signing secret, starting with whsec_, is shown once. If you lose it, register again.
Delivery
- Anything other than
2xx, and a 10 second timeout, count as failure. Redirects are not followed - Retries come after 1 minute and 5 minutes, then every 15 minutes, up to 24 hours after the first attempt
- The same event can arrive more than once. Remove duplicates with
Nodd-Event-Id - An
action.respondedevent that fails for 24 hours setsaction_statetofailed. The response stays readable withGET /v1/messages/{id}
Nodd-Signature verification
Events carry this header.
Nodd-Signature: t=1793592821,v1=5f2a...
v1 is the HMAC-SHA256, in hex, of the t value, a ., and the raw body bytes. The key is the whole signing secret string.
- Compute it on the raw body, before parsing the JSON
- Reject a
tolder than 5 minutes - Compare with a constant time function
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((item) => item.split(/=(.*)/s).slice(0, 2)));
const timestamp = Number(parts.t);
if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
const actual = Buffer.from(parts.v1, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
Events
action.responded | A response is settled. Includes response |
|---|---|
action.expired | The deadline and the 10 minute grace period passed. Includes the on_timeout value you sent |
action.cancelled | The sender cancelled it |
action.escalated | escalate_after passed with no response, and other people were told |
delivery.expired | Nobody's receipt was confirmed within the delivery window. Team only |
{
"id": "evt_01JBX7R4...",
"type": "action.responded",
"occurred_at": "2026-11-02T04:13:41Z",
"message_id": "msg_01JBX7Q2M4N8R5T6V9W0Y1Z2A3",
"metadata": {"pr": "482"},
"response": {
"option_id": "approve",
"actor": {"id": "usr_...", "display_name": "Sato"},
"assurance": "biometric",
"responded_at": "2026-11-02T04:13:40Z",
"signature": {"alg": "ES256", "key_id": "dev_...", "value": "...", "nonce": "..."}
}
}
Fields are id, type, occurred_at, message_id, and when present response, metadata, on_timeout, and approvals. metadata is what you sent. approvals lists approvals before settling, only with multiple approvers. action.expired does not make the server reject anything, so stop the work on your side.
Device signatures
response.signature is the signature from the device that responded. It shows the device signed the content, independently of the Nodd server.
alg | ES256 (P-256, SHA-256) |
|---|---|
key_id | Device ID |
value | Base64 of the 64 byte r and s concatenated |
nonce | A value included in what is signed |
What is signed is a UTF-8 string of these items joined by line feeds (\n). The first item is the version. It predates the product name, so it stays nod.
nod-respond-v1
A response with no reason.
nod-respond-v1\n{message_id}\n{option_id}\n{responded_at}\n{nonce}\n{assurance}
nod-respond-v2
A response with a reason. The reason can contain line feeds, so it comes last. Changing the reason breaks the signature.
nod-respond-v2\n{message_id}\n{option_id}\n{responded_at}\n{nonce}\n{assurance}\n{note}
responded_atis the string the device signed. Use it exactly as is- An empty
notemeansnod-respond-v1 assuranceisnoneorbiometric.biometricmeans Face ID was used