Callbacks and signatures

There are three ways to get responses.

PollingGET /v1/messages/{id}. Works with no public endpoint
callbackA POST to the URL you register
Events APIGET /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.

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

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.

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.respondedA response is settled. Includes response
action.expiredThe deadline and the 10 minute grace period passed. Includes the on_timeout value you sent
action.cancelledThe sender cancelled it
action.escalatedescalate_after passed with no response, and other people were told
delivery.expiredNobody'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.

algES256 (P-256, SHA-256)
key_idDevice ID
valueBase64 of the 64 byte r and s concatenated
nonceA 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}
Verifying this needs the device public key, and the public API has no way to get it at present. The server checks this signature when it accepts a response.