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: 配信単位の ULIDX-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 は配送投入時点のシークレットで署名されます(過去配送に影響なし)。
Reloura One マニュアル