応答の受け取りと署名

応答は 3 つの方法で受け取れます。

ポーリングGET /v1/messages/{id}。受け口がなくても使えます
callback登録した URL へ POST で届けます
イベント APIGET /v1/actions/events。読み取りキーで読みます

URL の登録

アプリのチャンネルの詳細で「応答の送信先」を開き、URL を入れて Face ID で登録します。登録できるのは Manager、Owner、Admin です。URL の条件は次のとおりです。

登録時に、次の本文を署名なしで送ります。

{"type":"verification","challenge":"chl_..."}

2xx の本文に challenge の値を含めて返した URL だけを登録します。返さないときは callback_unverified になります。

登録に成功すると、署名の秘密(whsec_ で始まる値)を 1 回だけ表示します。分からなくなったら登録し直してください。

届き方

Nodd-Signature の検証

イベントには次のヘッダーが付きます。

Nodd-Signature: t=1793592821,v1=5f2a...

v1 は、t の値、.、受け取った本文のバイト列をつないだものの HMAC-SHA256 です。鍵は署名の秘密の文字列全体で、結果は 16 進数です。

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);
}

イベント

action.responded応答が確定しました。response が付きます
action.expired応答期限と受付猶予の 10 分が過ぎました。送信時の on_timeout が付きます
action.cancelled送信元が取り下げました
action.escalatedescalate_after の時間が過ぎても応答がなく、別の担当へ知らせました
delivery.expired受け取り期限までに誰の受信も確認できませんでした。Team だけです
{
  "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": "佐藤"},
    "assurance": "biometric",
    "responded_at": "2026-11-02T04:13:40Z",
    "signature": {"alg": "ES256", "key_id": "dev_...", "value": "...", "nonce": "..."}
  }
}

項目は id、type、occurred_at、message_id と、あれば response、metadata、on_timeout、approvals です。metadata は送信時の値です。approvals は確定より前の承認で、2 人以上の承認だけに付きます。action.expired を受けても、サーバーが却下を実行するわけではありません。送った側で止めてください。

端末の署名

response.signature は、応答した端末の署名です。Nodd のサーバーとは別に、端末が内容に署名したことを示します。

algES256(P-256、SHA-256)
key_id端末の ID
value署名の Base64。r と s をつないだ 64 バイトです
nonce署名の対象に含まれる値

署名の対象は、次の項目を改行(\n)でつないだ UTF-8 の文字列です。先頭の文字列は版を示します。製品名の変更の前からの値なので、nod のままです。

nod-respond-v1

却下の理由がない応答です。

nod-respond-v1\n{message_id}\n{option_id}\n{responded_at}\n{nonce}\n{assurance}

nod-respond-v2

却下の理由がある応答です。理由は改行を含められるので、最後に置きます。理由を変えると署名が通らなくなります。

nod-respond-v2\n{message_id}\n{option_id}\n{responded_at}\n{nonce}\n{assurance}\n{note}
検証には端末の公開鍵が必要ですが、今の公開 API に端末の公開鍵を返す経路はありません。サーバーは応答を受けるときにこの署名を確かめています。