応答の受け取りと署名
応答は 3 つの方法で受け取れます。
| ポーリング | GET /v1/messages/{id}。受け口がなくても使えます |
|---|---|
| callback | 登録した URL へ POST で届けます |
| イベント API | GET /v1/actions/events。読み取りキーで読みます |
URL の登録
アプリのチャンネルの詳細で「応答の送信先」を開き、URL を入れて Face ID で登録します。登録できるのは Manager、Owner、Admin です。URL の条件は次のとおりです。
- https の 443 番で、512 文字まで
- 公開アドレスに解決できる名前付きホスト。IP アドレスと認証情報を含む URL は使えません
登録時に、次の本文を署名なしで送ります。
{"type":"verification","challenge":"chl_..."}
2xx の本文に challenge の値を含めて返した URL だけを登録します。返さないときは callback_unverified になります。
登録に成功すると、署名の秘密(whsec_ で始まる値)を 1 回だけ表示します。分からなくなったら登録し直してください。
届き方
2xx以外と 10 秒のタイムアウトは失敗とします。リダイレクトは追いません- 失敗すると 1 分後、5 分後に再送し、その後は 15 分おきに、最初の試行から 24 時間まで再送します
- 同じイベントが複数回届くことがあります。
Nodd-Event-Idで重複を除いてください - 24 時間届かなかった
action.respondedは、action_stateがfailedになります。応答はGET /v1/messages/{id}で読めます
Nodd-Signature の検証
イベントには次のヘッダーが付きます。
Nodd-Signature: t=1793592821,v1=5f2a...
v1 は、t の値、.、受け取った本文のバイト列をつないだものの HMAC-SHA256 です。鍵は署名の秘密の文字列全体で、結果は 16 進数です。
- JSON を読み直す前の、受け取ったままの本文で計算します
- 5 分より古い
tは拒否します - 比較には定数時間の関数を使います
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.escalated | escalate_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 のサーバーとは別に、端末が内容に署名したことを示します。
alg | ES256(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}
responded_atは端末が署名した文字列です。形式を変えずにそのまま使いますnoteが空のときはnod-respond-v1ですassuranceはnoneかbiometricです。biometricは Face ID を通した応答です