Webhookで新しい売上を受け取り、Slackに通知する
この記事では、ビジネスアカウントに新しい売上が記録されるたびにレシートローラーから届く receipt.created Webhookを受け取り、署名を検証し、重複を除いたうえで、Slackのチャンネルに1行のメッセージを投稿する小さなNode.jsサーバーを作ります。WebhookはAPIで登録し、テスト送信と配信ログで動作を確認します。
必要なもの
- 開発者ポータルに登録済みのアプリ(アプリを新規作成する)
- スタータープラン以上のビジネスアカウント。デベロッパーAPIはスターター以上のプランに含まれます。テスト用のサンドボックスはないため、実際の(またはテスト用に入力した)売上が入るビジネスアカウントを使います
- そのビジネスアカウントに対して発行したアクセストークン。ビジネスアカウントのオーナーまたは管理者がアプリを承認すると、トークンがそのビジネスアカウントに紐づきます(はじめてのAPI呼び出し:アプリ登録から取引一覧の取得まで)。Webhook APIは特定のスコープを要求しませんが、トークンがビジネスアカウントに紐づいている必要があります
- Node.js 20以上と Express
- Slackの Incoming Webhook URL(Slackのアプリ設定で「Incoming Webhooks」を有効にし、投稿先チャンネルを選ぶと発行されます)
- インターネットから届く HTTPS の受信URL。
localhostやプライベートIPアドレスは登録できないため、手元で試すときは ngrok や Cloudflare Tunnel などのトンネルを使います
ステップ1:署名用シークレットを決める
Webhookの署名に使うシークレットは、登録時に自分で指定するか、省略して自動生成させるかを選べます。自分で指定する場合は16〜64文字です。ここでは先に作っておき、受信サーバーと登録の両方で使います。
openssl rand -hex 24
出力された48文字を {webhook_secret} として以降で使います。省略した場合は32文字のシークレットが生成され、登録APIのレスポンスで一度だけ返ります。あとから取得するAPIはありません。
ステップ2:受信サーバーを書く
mkdir rr-slack && cd rr-slack
npm init -y
npm install express
server.mjs を作ります。
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.RR_WEBHOOK_SECRET;
const SLACK_URL = process.env.SLACK_WEBHOOK_URL;
const PORT = process.env.PORT || 3000;
// 処理済みの X-RR-Delivery。再送は最初の送信から約71分以内に終わるので、3時間覚えておけば足ります。
// 本番では Redis やデータベースに置き換えてください(プロセスを再起動すると消えるため)。
const processed = new Map();
const KEEP_MS = 3 * 60 * 60 * 1000;
function remember(deliveryId) {
processed.set(deliveryId, Date.now());
for (const [id, at] of processed) {
if (Date.now() - at > KEEP_MS) processed.delete(id);
}
}
// X-RR-Signature は "sha256=" + 生のリクエストボディの HMAC-SHA256(小文字hex)
function hasValidSignature(rawBody, header) {
if (!header || !Buffer.isBuffer(rawBody)) return false;
const expected = "sha256=" +
crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function slackText(event) {
if (event.event === "test.ping") {
return "レシートローラーからのテスト通知を受信しました";
}
const sale = event.data;
const amount = new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: sale.currency || "JPY",
}).format(sale.total_amount);
return `新しい売上 ${amount}(店舗 ${sale.store_id} / レシート番号 ${sale.receipt_number || "-"} / ${sale.payment_method || "-"})`;
}
function isVoidOrRefund(event) {
const status = (event.data?.status || "").toLowerCase();
return status === "voided" || status === "refunded";
}
const app = express();
// 署名は受信したバイト列そのものに対して計算されるので、JSON にパースする前のボディを使います
app.post("/rr-webhook", express.raw({ type: "application/json" }), async (req, res) => {
if (!hasValidSignature(req.body, req.get("X-RR-Signature"))) {
return res.status(401).end();
}
const deliveryId = req.get("X-RR-Delivery");
if (processed.has(deliveryId)) {
return res.status(200).end(); // 再送された同じイベント。処理済みなので受け取ったことだけ返す
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.event === "receipt.created" && isVoidOrRefund(event)) {
remember(deliveryId);
return res.status(200).end();
}
try {
const slack = await fetch(SLACK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: slackText(event) }),
signal: AbortSignal.timeout(5000),
});
if (!slack.ok) throw new Error(`Slack returned ${slack.status}`);
} catch (err) {
console.error(deliveryId, err);
return res.status(502).end(); // 2xx 以外を返すとレシートローラーが再送します
}
remember(deliveryId);
res.status(200).end();
});
app.listen(PORT, () => console.log(`listening on ${PORT}`));
起動します。
RR_WEBHOOK_SECRET={webhook_secret} \
SLACK_WEBHOOK_URL={slack_incoming_webhook_url} \
node server.mjs
別のターミナルでトンネルを開き、表示された HTTPS のURLに /rr-webhook を付けたものを {receiver_url} とします(例:https://xxxx.ngrok-free.app/rr-webhook)。
ngrok http 3000
このサーバーのポイントは次のとおりです。
- 署名:
X-RR-Signatureの値はsha256=に続けて、生のリクエストボディ(UTF-8のバイト列)を Webhook のシークレットをキーにして HMAC-SHA256 で計算した小文字の16進文字列です。タイムスタンプは署名に含まれません - 重複除去:
X-RR-DeliveryはイベントごとのIDで、ペイロードのidと同じ値です。同じイベントの再送ではこの値もボディも変わりません - 時間:レシートローラーは10秒で応答を待つのをやめ、失敗として扱います。Slackへの送信は5秒で打ち切っています
- 取消・返品:
receipt.createdは状態では絞り込まれていません。売上だけを通知したいので、statusがVoidedまたはRefundedのものは投稿せずに受け取りだけ返しています
ステップ3:Webhookを登録する
curl -X POST https://receiptroller.io/api/v1/webhooks \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"url": "{receiver_url}",
"eventTypes": ["receipt.created"],
"secret": "{webhook_secret}"
}'
ステータス 201 で次のようなレスポンスが返ります。id が以降の {webhook_id} です。secret はこのレスポンスにしか含まれません。
{
"id": "wh_xxxxxxxxxxxxxxxx",
"clientId": "{client_id}",
"organizationId": "{organizationId}",
"url": "{receiver_url}",
"eventTypes": ["receipt.created"],
"active": true,
"createdAt": "2026-10-26T03:00:00.0000000Z",
"lastDeliveredAt": null,
"secret": "{webhook_secret}"
}
Webhookはアプリとトークンのビジネスアカウントに結びつきます。届くのは、そのビジネスアカウントのイベントだけです。複数のビジネスアカウントの売上を受け取るときは、それぞれのビジネスアカウントのトークンで登録します。開発者ポータルの画面から登録することもできます。
このステップでよくあるエラー
| ステータス | メッセージ | 対処 |
|---|---|---|
| 401 | (本文なし) | トークンがない、期限切れ、または無効です。リフレッシュトークンで取り直します |
| 400 | organization_id not found in token. |
トークンがビジネスアカウントに紐づいていません。ビジネスアカウント向けの承認フローで取得したトークンを使います |
| 400 | url must be a valid HTTPS address. |
https:// で始まる絶対URLにします |
| 400 | url must not point to localhost or private IP ranges. |
トンネルなどで外から届くURLを使います |
| 400 | event_types must contain at least one event. |
フィールド名は eventTypes です。event_types と書くと読み取られず、このエラーになります |
| 400 | Unknown event types: ... |
イベント名のつづりを確認します(receipt.created) |
| 400 | secret must be 16 to 64 characters, or omitted to have one generated. |
16〜64文字にするか、secret を省略します |
トークン取得の段階で invalid_grant が返り、説明に needs Starter or above とある場合は、ビジネスアカウントのプランがデベロッパーAPIを含んでいません(プランがフリーのまま開発者ポータルが表示されない)。
ステップ4:テストイベントを送る
curl -X POST https://receiptroller.io/api/v1/webhooks/test/{webhook_id} \
-H "Authorization: Bearer {access_token}"
レシートローラーが受信URLに test.ping を1回送り、その結果を返します。Slackに「テスト通知を受信しました」と投稿されれば、署名検証から転送までつながっています。
{
"webhookId": "wh_xxxxxxxxxxxxxxxx",
"httpStatus": 200,
"success": true,
"deliveredAt": "2026-10-26T03:01:00.0000000Z"
}
受信URLに届くボディは次の形です。
{
"id": "evt_1a2b3c4d5e6f",
"event": "test.ping",
"timestamp": "2026-10-26T03:01:00.0000000Z",
"organization_id": "{organizationId}",
"data": { "message": "This is a test ping from Receipt Roller." }
}
- APIの応答はつねに
200です。届いたかどうかはsuccessとhttpStatusで判断します。httpStatusが0のときは接続そのものに失敗しています(URL、トンネル、TLS証明書を確認) httpStatusが401なら受信サーバーが署名を拒否しています。サーバーのRR_WEBHOOK_SECRETと登録時のsecretが一致しているかを確認します- テスト送信は購読しているイベントや
activeの状態に関係なく1回だけ送られ、再送されず、配信ログにも残りません 404は、{webhook_id}が違うか、別のアプリのWebhookです
ステップ5:実際の売上で確かめる
ビジネスアカウントの店舗で売上を1件記録します(連携しているPOSやレジ、決済端末、CSVの取り込みのいずれでも構いません)。受信サーバーには次のようなボディが、ヘッダー X-RR-Event: receipt.created 付きで届きます。
{
"id": "evt_9f8e7d6c5b4a",
"event": "receipt.created",
"timestamp": "2026-10-26T03:15:43.1234567Z",
"organization_id": "{organizationId}",
"data": {
"id": "{terminal_id}|2026-10|{transaction_id}",
"terminal_id": "{terminal_id}",
"transaction_id": "{transaction_id}",
"external_transaction_id": "{external_transaction_id}",
"store_id": "{store_id}",
"receipt_number": "R-000123",
"issued_at": "2026-10-26T03:15:42Z",
"status": "Completed",
"total_amount": 1100,
"tax_amount": 100,
"currency": "JPY",
"payment_method": "...",
"item_count": 2,
"source": "...",
"receipt_url": "..."
}
}
エンベロープ(id・event・timestamp・organization_id・data)と data の中身は、どちらもスネークケースです。data には顧客を特定する情報は含まれません。明細や会員の情報が必要なときは、terminal_id と transaction_id を手がかりに取引一覧APIや売上実績APIで取得します。
receipt.created が送られる場合と送られない場合
- 送られる:その売上がレシートローラーに初めて保存されたとき。1件の売上につき1回です
- 送られない:POS側からの同じ売上の再送、定期的な再同期、同じ期間のCSVの再取り込み。いずれも既存の売上を書き直すだけなので、通知は出ません
- 送られない:削除された売上
- 状態では絞り込まれないため、初めて保存された取引が取消(
Voided)や返品(Refunded)の場合も送られます。上のサーバーはこれを投稿せずにスキップしています
このクイックスタートで受け取る売上イベントは receipt.created です。現在実際に送信されるイベントは receipt.created、order.created、order.updated、test.ping の4つです。そのほかのイベント名(receipt.updated など)も登録はできますが、まだ送信されません。
ステップ6:配信ログを確認する
curl https://receiptroller.io/api/v1/webhooks/{webhook_id}/deliveries \
-H "Authorization: Bearer {access_token}"
直近50件の配信の試行が新しい順に返ります。再送はそれぞれ1行として記録され、attemptCount が何回目の試行かを示します。
[
{
"id": "wdl_xxxxxxxxxxxx",
"webhookId": "wh_xxxxxxxxxxxxxxxx",
"eventType": "receipt.created",
"httpStatus": 200,
"success": true,
"attemptCount": 1,
"errorMessage": null,
"attemptedAt": "2026-10-26T03:15:44.0000000"
}
]
接続できなかった試行は httpStatus が 0 になり、errorMessage に理由が入ります。
再送と、Webhookが止まる条件
- 受信側が
2xxを返せば成功です。それ以外のステータス、接続エラー、10秒以内に応答がない場合は失敗です - 失敗すると、1分後、10分後、1時間後に再送します。最初の送信と合わせて最大4回です。再送のボディと
X-RR-Deliveryは最初の送信と同じです - 4回とも失敗すると、そのWebhookは無効(
active: false)になり、以後のイベントは送られません。無効になっている間のイベントは保留されません - 受信側を直したら、次のように有効に戻します
curl -X PATCH https://receiptroller.io/api/v1/webhooks/{webhook_id} \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{ "active": true }'
上のサーバーはSlackへの投稿に失敗すると 502 を返し、再送で投稿をやり直します。Slackが1時間以上止まると、そのイベントで4回失敗してWebhookが無効になる点に注意してください。取りこぼしより停止を避けたい場合は、先に 200 を返してから非同期にSlackへ送る作りにします。
次のステップ
-
はじめてのAPI呼び出し:アプリ登録から取引一覧の取得までOAuth認可コードフロー(PKCE)でトークンを取得し、ビジネスアカウントを確認して取引一覧を取得、トークンを更新するまでを通して行います。
-
MCPでAIエージェントをレシートローラーに接続するClaudeをMCPコネクタとして接続する手順と、動的クライアント登録・PKCE・JSON-RPCで自作のMCPクライアントから呼び出す方法を解説します。
-
店舗の予約を自社のカレンダーに表示する予約管理APIで店舗の7日分の予約を取得し、Node.jsでiCalendar(.ics)ファイルに書き出してカレンダーに表示する手順です。
-
商品マスタを自社システムと同期する手元のCSV・JSONを読み、SKUで照合して商品を作成・差分更新するNode.jsスクリプトを、商品APIで作ります。