API
Cloudflare Workers を GQ CRM のマルチプロジェクト配信ルーターにする
1 つの Worker で複数プロジェクトを扱います。プロジェクト別 API キーで送信を振り分け、GQ CRM の Webhook 署名を検証し、配信 ID で重複排除し、projectId でイベントをルーティングします。実際に引っかかる制限も掲載。
公開日 2026年9月21日
複数の送信プロジェクト——クライアントごと、ブランドごと、環境ごと——を運用していると、ある送信やイベントがどのプロジェクトに属するかを決める 1 か所が欲しくなります。Cloudflare Worker はこれに向いています。エッジにいて、安価で、プロジェクトごとの API キーを暗号化された secret として保持できます。
系統は 2 つあり、互いに独立しています。
- アウトバウンド: cron トリガーが Worker を起動し、正しいプロジェクトのキーを選んで GQ CRM の Send API を呼びます。
- インバウンド: GQ CRM が
email.sent/bounced/complained/unsubscribedを1 つの Worker URL に push し、Worker が署名を検証してprojectIdでルーティングします。
ワークスペースあたり Webhook エンドポイントは最大 10 個なので、「1 エンドポイントをプロジェクトで振り分ける」形のほうが通常は望ましく、プロジェクトごとに 1 エンドポイントではありません。
はじめる前に
- 送信アイデンティティごとに 1 つの GQ CRM プロジェクト(独自の SMTP 設定、テンプレート、API キー)。API アクセスには Starter 以上が必要です。
- プロジェクトごとの API キー。できれば
send:emailスコープ、有効期限、そして(固定の送信元 IP がある場合のみ)IP 許可リスト付き。 - Wrangler を入れた Cloudflare アカウントと、ルーティング状態の置き場所(最初は KV で十分、厳密な一貫性が必要なら D1)。
ステップ 1: プロジェクトごとに 1 つのキーを Worker secret に
キーを wrangler.toml やソースにコミットしないでください。secret として保存し、バインディング名で参照します。
wrangler secret put GQ_KEY_ACME
wrangler secret put GQ_KEY_NORTHWIND
そして「テナント → バインディング名」のルーティング表を持ちます。「テナント → キー」ではないので、ローテーションでコードに触れることはありません。
name = "mail-router"
main = "src/index.js"
compatibility_date = "2026-09-01"
[triggers]
crons = ["*/5 * * * *"]
[[kv_namespaces]]
binding = "ROUTING"
id = "<KV namespace の id>"
ステップ 2: プロジェクトごとに送信を振り分ける
Worker の仕事は小さいものです。テナントを引き、そのキーを解決し、送る。安全にリトライできるようにする要点は 2 つ——プロジェクト別のキーと、タイムスタンプではなくビジネスイベントから導出した冪等キーです。
async function sendForProject(env, tenant, message) {
const binding = await env.ROUTING.get(`tenant:${tenant}`);
if (!binding) throw new Error(`unknown tenant: ${tenant}`);
const res = await fetch('https://gq-crm.com/api/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': env[binding],
'Idempotency-Key': message.businessKey, // 例: invoice-2026-0042-reminder
},
body: JSON.stringify({ to: message.to, subject: message.subject, html: message.html }),
});
if (res.status === 429) throw new Error('rate limited — back off and retry');
if (!res.ok) throw new Error(`send failed: ${res.status} ${await res.text()}`);
return res.json();
}
レート制限は「プロジェクト + 分」で数えられます(Free 60、Starter 300、Professional 1000)。各テナントのトラフィックが個別に制御されていれば、テナントをまたぐループが上限を超えることは自然にありません。同じ Idempotency-Key を 24 時間以内に再利用すると、再送ではなく最初のレスポンスが再生されます。部分失敗のあとに cron を再実行しても安全なのはこのためです。
ステップ 3: 1 つの URL で Webhook を受け取る
GQ CRM はすべての配信に署名します: X-Webhook-Signature: t=<unix>,v1=<hex hmac-sha256(secret, "t.rawBody")>。生のボディに対して検証し、古いタイムスタンプは拒否します。
async function verify(request, secret, rawBody) {
const parts = Object.fromEntries(
(request.headers.get('X-Webhook-Signature') || '').split(',').map((p) => p.split('=')),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 分の窓
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${rawBody}`));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
const got = (parts.v1 || '').toLowerCase();
if (expected.length !== got.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ got.charCodeAt(i);
return diff === 0;
}
export default {
async fetch(request, env, ctx) {
const rawBody = await request.text(); // JSON 解析より前
if (!(await verify(request, env.GQ_WEBHOOK_SECRET, rawBody))) {
return new Response('bad signature', { status: 401 });
}
const deliveryId = request.headers.get('X-Webhook-Delivery-Id');
if (deliveryId && (await env.ROUTING.get(`seen:${deliveryId}`))) {
return new Response('ok', { status: 200 }); // 配信は at-least-once
}
if (deliveryId) ctx.waitUntil(env.ROUTING.put(`seen:${deliveryId}`, '1', { expirationTtl: 604800 }));
ctx.waitUntil(handle(JSON.parse(rawBody), env)); // 先に応答し、処理は後で
return new Response('ok', { status: 200 });
},
};
ここで効くルールは 3 つです。署名は正確なバイト列を対象にするので、解析の前に検証すること。短絡する === は情報を漏らすので、定時安全な比較を使うこと。配信は at-least-once で、遅い応答のあとのリトライは普通なので、X-Webhook-Delivery-Id で重複排除すること。
ステップ 4: プロジェクトでルーティングする
すべてのイベント payload は data.projectId を持つので、1 つのハンドラでプロジェクトごとに振り分けられます。
async function handle(event, env) {
const { projectId } = event.data;
switch (event.event) {
case 'email.bounced':
case 'email.complained':
return env.ROUTING.put(`suppress:${projectId}:${event.data.to}`, '1');
case 'email.unsubscribed':
return env.ROUTING.put(`unsub:${projectId}:${event.data.to}`, '1');
case 'email.sent':
return env.ROUTING.put(`last-sent:${projectId}`, event.createdAt);
default:
return null;
}
}
プロジェクト別の抑制リストを自分のストレージに持つ価値は、この数行にあります。バウンスの後に気づくのではなく、API を呼ぶ前に確認できるからです。本文をリモートに保存したくない場合、GQ CRM は email.sent と email.failed の payload 本文を暗号化できます。鍵はエンドポイントの secret から導出されるので、Worker は既に持っている secret で復号できます。
ステップ 5(任意): Email Routing でインバウンドを受け取る
ここまでの系統はアウトバウンド送信とプラットフォームイベントを扱います。プロジェクトごとのインバウンドアドレスも欲しい場合、Cloudflare Email Routing がドメイン宛のメールを受け取り、Worker の email ハンドラに渡します。そこで宛先アドレスに応じて各プロジェクトの受信箱やストレージへルーティングします。GQ CRM はアウトバウンド送信を担い、インバウンドは Cloudflare 側の担当で、2 つの関心事は分かれたままです。
実際に引っかかる制限
- Worker には既定で安定した送信元 IP がありません。 API キーの CIDR 許可リストは Worker からの呼び出しを拒否するので、固定 IP を持つ何かを前段に置くか、プロジェクトごとのキー + スコープ + 有効期限で運用してください。
- KV は結果整合です。 並行する 2 つの配信がどちらも
seen:キーを見逃す可能性があります。重複排除が厳密でなければならないなら D1 か Durable Object を使ってください。 - Webhook のリトライは終わります。 失敗は 1m/5m/30m/2h/6h のバックオフでリトライされ、その後
DEADになります——およそ 8.6 時間の猶予です。リトライがいつか成功すると決めつけず、DEAD 配信にアラートを出してください。 - 受信側は速くなければいけません。 配信は 10 秒でタイムアウトし、リダイレクトは追わず、2xx だけが成功です。まず応答し、処理は
waitUntilに渡します。 - エンドポイントは公開 HTTPS でなければいけません。 エンドポイント作成時に GQ CRM はドメインを解決し、プライベート、ループバック、リンクローカルを拒否します。
workers.devの URL でもカスタムドメインでも動きますが、ノート PC へのトンネルは通りません。 - cron の粒度は 1 分です。 秒単位のスケジューリングが必要なルーターは設計しないでください。
検証チェックリスト
- 既知のテナントとして Worker 経由で 1 通送り、同じジョブを再実行します。2 回目は再送ではなく最初のレスポンスの再生になるはずです。
- 署名が有効なキャプチャ済み Webhook ボディをリプレイします。1 回目は 200、2 回目は無視されるはずです。
- 改ざんしたボディ、または古いタイムスタンプでリプレイします。401 になるはずです。
- テスト用エンドポイントを Worker に向け、バウンスを発生させ、抑制キーが正しい
projectIdの下に入ることを確認します。
送信側は Send API クイックスタート とプロジェクトと API キーを参照してください。リレーが SES なら、Amazon SES の接続が SMTP 側を扱っています。