API

Cloudflare Workers as a Multi-Project Mail Router for GQ CRM

One Worker, many projects: fan out sends with per-project API keys, verify GQ CRM webhook signatures, deduplicate deliveries by ID, and route events by projectId. Includes the limits that bite.

Published September 21, 2026

If you run more than one sending project — one per client, per brand, or per environment — you eventually want a single place that decides which project a send or an event belongs to. A Cloudflare Worker is a good fit: it sits at the edge, it's cheap, and it can hold one API key per project as an encrypted secret.

There are two flows, and they are independent:

  • Outbound: a cron trigger wakes the Worker, which picks the right project key and calls the GQ CRM Send API.
  • Inbound: GQ CRM pushes email.sent / bounced / complained / unsubscribed events to one Worker URL, which verifies the signature and routes by projectId.

Because a workspace allows up to 10 webhook endpoints, "one endpoint, routed by project" is usually the pattern you want rather than one endpoint per project.

Before you start

  • A GQ CRM project per sending identity — its own SMTP config, templates, and API key. API access requires the Starter plan or above.
  • An API key per project, ideally with a scope of send:email, an expiry date, and (only if you have fixed egress) an IP allowlist.
  • A Cloudflare account with Wrangler installed, and a place to keep routing state (KV is fine to start, D1 if you need real consistency).

Step 1: one key per project, as a Worker secret

Never commit keys into wrangler.toml or source. Store them as secrets and reference them by binding name:

wrangler secret put GQ_KEY_ACME
wrangler secret put GQ_KEY_NORTHWIND

Then keep a routing table that maps a tenant to a binding name — not to a key — so rotation never touches your code:

name = "mail-router"
main = "src/index.js"
compatibility_date = "2026-09-01"

[triggers]
crons = ["*/5 * * * *"]

[[kv_namespaces]]
binding = "ROUTING"
id = "<your-kv-namespace-id>"

Step 2: fan out sends per project

The Worker's job is small: look up the tenant, resolve its key, send. Two details make it safe to retry — a per-project key, and an idempotency key derived from the business event rather than from a timestamp.

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, // e.g. 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();
}

Rate limits are counted per project per minute (60 on Free, 300 on Starter, 1000 on Professional), so a fan-out loop across many tenants naturally stays under the ceiling as long as each tenant's traffic is paced separately. Reusing the same Idempotency-Key within 24 hours replays the original response instead of sending twice — which is what makes it safe for the cron to run again after a partial failure.

Step 3: receive webhooks on one URL

GQ CRM signs every delivery with X-Webhook-Signature: t=<unix>,v1=<hex hmac-sha256(secret, "t.rawBody")>. Verify against the raw body and reject stale timestamps:

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-minute window

  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(); // before any JSON parsing
    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 }); // deliveries are at-least-once
    }
    if (deliveryId) ctx.waitUntil(env.ROUTING.put(`seen:${deliveryId}`, '1', { expirationTtl: 604800 }));

    ctx.waitUntil(handle(JSON.parse(rawBody), env)); // ack fast, work later
    return new Response('ok', { status: 200 });
  },
};

Three rules matter here. Verify before parsing, because the signature covers the exact bytes. Compare in constant time, because a short-circuiting === leaks information. And deduplicate on X-Webhook-Delivery-Id, because delivery is at-least-once and a retry after a slow response is normal.

Step 4: route by project

Every event payload carries data.projectId, so one handler can dispatch per project:

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;
  }
}

Keeping a per-project suppression list in your own storage is worth the few lines: it lets you check before calling the API, instead of discovering after a bounce. If you'd rather not store bodies remotely, GQ CRM can encrypt email.sent and email.failed payload bodies — the key is derived from the endpoint secret, so the Worker decrypts with the secret it already has.

Step 5 (optional): inbound mail with Email Routing

The flows above cover outbound sends and platform events. If you also want per-project inbound addresses, Cloudflare Email Routing can catch mail for a domain and hand it to a Worker's email handler, where you route on the recipient address to the right project's inbox or storage. GQ CRM handles outbound sending; inbound is Cloudflare's side of the fence, which keeps the two concerns separate.

Limits that actually bite

  • Workers have no stable egress IP by default. A CIDR allowlist on an API key will reject Worker-originated calls, so either front the request with something that has a fixed IP, or rely on one key per project plus scope and expiry instead.
  • KV is eventually consistent. Two concurrent deliveries can both miss the seen: key. Use D1 or a Durable Object if your dedupe must be exact.
  • Webhook retries end. Failures retry on a 1m/5m/30m/2h/6h backoff and are then marked DEAD — roughly 8.6 hours of runway. Alert on dead deliveries rather than assuming a retry will eventually land.
  • The receiver must be fast. Deliveries time out at 10 seconds, redirects are not followed, and only a 2xx counts as success. Acknowledge first and do the work in waitUntil.
  • The endpoint must be public HTTPS. GQ CRM resolves the domain when you create the endpoint and rejects private, loopback, and link-local targets, so a workers.dev URL or a custom domain both work; a tunnel to your laptop does not.
  • Cron granularity is one minute. Don't design a router that needs sub-minute scheduling.

Verification checklist

  1. Send one message through the Worker for a known tenant, then re-run the same job — the second run should replay the first response, not send again.
  2. Replay a captured webhook body with a valid signature: it should return 200 once and be ignored on the second attempt.
  3. Replay it with a tampered body or an old timestamp: it should return 401.
  4. Point a test endpoint at the Worker, trigger a bounce, and confirm the suppression key lands under the right projectId.

For the sending side, see the Send API quickstart and Projects and API keys. If your relay is SES, How to Connect Amazon SES covers the SMTP half.