Reloura One マニュアル

Webhook配信

Reloura One で発生したイベント(コンタクト作成 / HOT 判定 / 売上記録など)を、外部の URL に HTTP POST で通知する仕組みです。Slack / Discord / Notion / Zapier / Make などと連携できます。

主な用途

  • HOT 判定を Slack / Discord に流して見逃しを防ぐ
  • 購入記録を Google Sheets / Notion に蓄積する
  • 別の MA ツール / CRM とコンタクトを同期する
  • Zapier / Make / n8n でカスタム自動化を組む

セットアップ手順

1

エンドポイントを作成

「設定」→「Webhook 配信」→「新しい Webhook を追加」。名前・宛先 URL(HTTPS 必須)・購読イベントを指定して作成すると、シークレットが 1 度だけ表示されます。安全な場所に保管してください。

2

受信側で署名を検証

受信した HTTP リクエストの X-Reloura-Signature ヘッダーを HMAC-SHA256 で検証します(後述のサンプルコード参照)。

3

テスト送信で動作確認

エンドポイント詳細画面のテスト送信フォームから、サンプルペイロードを実エンドポイントに送信できます。配信履歴でレスポンスを確認します。

利用可能なイベント

イベントタイプ 説明
contact.createdコンタクトが新規作成された
contact.tag_attachedコンタクトにタグが付与された
contact.tag_detachedコンタクトからタグが解除された
lead.hot_detectedスコアが閾値に到達して HOT 判定された
revenue.recorded売上が記録された(現状は Stripe Webhook が起点)
optin_form.submitted公開フォームが送信された(確認メール送信時点)
broadcast.completed一斉配信が全件成功で完了した
broadcast.failed一斉配信が一部失敗で完了した
email.bouncedメールバウンスで配信除外(サプレッション)された
email.complainedメール苦情で配信除外(サプレッション)された
inquiry.created運営チームへのお問合せが作成された

ペイロード共通形式

全イベント共通のエンベロープ:

{
  "id": "evt_lead_hot_detected_contact_xxx_1714627200",
  "type": "lead.hot_detected",
  "version": "2026-05-02",
  "created_at": "2026-05-02T10:00:00+09:00",
  "tenant": { "public_id": "tenant_xxx" },
  "data": { /* イベント固有 */ }
}

主要 HTTP ヘッダー:

  • X-Reloura-Event: イベントタイプ
  • X-Reloura-Event-Id: 業務イベント単位の冪等キー(再送でも同一)
  • X-Reloura-Delivery: 配信単位の ULID
  • X-Reloura-Signature: t=<unix>,v1=<hex_hmac_sha256> 形式
  • X-Reloura-Payload-Version: ペイロードバージョン(YYYY-MM-DD)
  • X-Reloura-Secret-Version: シークレットバージョン

署名検証サンプルコード

署名対象は timestamp + "." + raw_body。受信側は 5 分以上古い timestamp は拒否することを推奨します(再生攻撃対策)。

Node.js (Express)

const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.RELOURA_WEBHOOK_SECRET;

app.use(express.raw({ type: 'application/json' }));

// 将来の v0 / v2 / 複数署名に備えて、カンマ区切りの key=value を分解する
function parseSignature(header) {
  const parts = Object.fromEntries(header.split(',').map(s => s.split('=')));
  if (!parts.t || !parts.v1) return null;
  return { t: parseInt(parts.t, 10), v1: parts.v1 };
}

app.post('/webhook', (req, res) => {
  const sig = parseSignature(req.headers['x-reloura-signature'] || '');
  if (!sig) return res.status(400).send('bad signature');
  const { t, v1 } = sig;

  if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).send('timestamp expired');

  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${t}.${req.body.toString('utf8')}`)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
    return res.status(400).send('invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  console.log('OK', event.type, event.id);
  res.status(200).send('ok');
});

PHP

<?php
$secret = getenv('RELOURA_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_X_RELOURA_SIGNATURE'] ?? '';

// 将来の v0 / v2 / 複数署名に備えて、カンマ区切りの key=value を分解する
$parts = [];
foreach (explode(',', $sigHeader) as $segment) {
    $kv = explode('=', $segment, 2);
    if (count($kv) === 2) $parts[$kv[0]] = $kv[1];
}

if (!isset($parts['t'], $parts['v1'])) {
    http_response_code(400);
    exit('bad signature');
}

[$t, $v1] = [(int) $parts['t'], $parts['v1']];
if (abs(time() - $t) > 300) {
    http_response_code(400);
    exit('timestamp expired');
}

$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
if (!hash_equals($expected, $v1)) {
    http_response_code(400);
    exit('invalid signature');
}

$event = json_decode($rawBody, true);
error_log('OK ' . $event['type'] . ' ' . $event['id']);
http_response_code(200);

Python (Flask)

import hmac, hashlib, os, time
from flask import Flask, request

SECRET = os.environ['RELOURA_WEBHOOK_SECRET'].encode()
app = Flask(__name__)


def parse_signature(header: str):
    # 将来の v0 / v2 / 複数署名に備えて、カンマ区切り key=value を分解する
    parts = dict(s.split('=', 1) for s in header.split(',') if '=' in s)
    if 't' not in parts or 'v1' not in parts:
        return None
    return int(parts['t']), parts['v1']


@app.route('/webhook', methods=['POST'])
def webhook():
    parsed = parse_signature(request.headers.get('X-Reloura-Signature', ''))
    if parsed is None:
        return 'bad signature', 400
    t, v1 = parsed

    if abs(time.time() - t) > 300:
        return 'timestamp expired', 400

    raw = request.get_data()
    expected = hmac.new(SECRET, f'{t}.{raw.decode()}'.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        return 'invalid signature', 400

    event = request.get_json()
    print('OK', event['type'], event['id'])
    return 'ok', 200

配信ポリシー

  • 順序非保証: 並列リトライの性質上、配信順は保証されません。受信側は X-Reloura-Event-Id で重複排除する冪等処理を実装してください。
  • リトライ: 5xx / 408 / 429 は 1m → 5m → 30m → 2h → 12h の指数バックオフで最大 5 回まで再試行。4xx(408/429 を除く)は即失敗。
  • 自動無効化: 連続 20 回失敗で自動無効化、テナントオーナー / 管理者にメール通知。
  • SSRF 対策: 内部 IP(127/8、RFC1918、169.254/16、IPv6 内部帯域等)への送信はブロック。リダイレクトは追従しません。
  • シークレットローテーション: 詳細画面の「シークレットを再生成」で新シークレットに切替。配送中の Webhook は配送投入時点のシークレットで署名されます(過去配送に影響なし)。