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 分です。 秒単位のスケジューリングが必要なルーターは設計しないでください。

検証チェックリスト

  1. 既知のテナントとして Worker 経由で 1 通送り、同じジョブを再実行します。2 回目は再送ではなく最初のレスポンスの再生になるはずです。
  2. 署名が有効なキャプチャ済み Webhook ボディをリプレイします。1 回目は 200、2 回目は無視されるはずです。
  3. 改ざんしたボディ、または古いタイムスタンプでリプレイします。401 になるはずです。
  4. テスト用エンドポイントを Worker に向け、バウンスを発生させ、抑制キーが正しい projectId の下に入ることを確認します。

送信側は Send API クイックスタート とプロジェクトと API キーを参照してください。リレーが SES なら、Amazon SES の接続が SMTP 側を扱っています。