Webhookで新しい売上を受け取り、Slackに通知する

クイックスタート Webhook receipt.created 署名検証 Slack Node.js

この記事では、ビジネスアカウントに新しい売上が記録されるたびにレシートローラーから届く 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へ送る作りにします。

次のステップ

公開日: 2026-10-26 更新日: 2026-10-26