Cloudflare Worker: Sume webhook to GitHub repository_dispatch

A Worker can verify the Sume signature with Web Crypto and forward only the job id and URL to GitHub's repository_dispatch endpoint, which answers 204.

4 min readSume
All posts

A Cloudflare Worker is a small place to terminate a Sume job webhook: it verifies the signature with Web Crypto, then calls GitHub's repository_dispatch endpoint so an Actions workflow runs when the asset is ready. Forward only the job id, the event name and the result URL, because GitHub caps the payload.

Do the verification in the Worker, not in the workflow. By the time a workflow starts, the unsigned dispatch looks like any other trigger, and anyone with a token that can dispatch could fake it.

What each side requires

Sume signs {timestamp}.{raw_body} with HMAC-SHA256 and sends sume-v1=<hex> in x-sume-webhook-signature, with several comma-separated entries while a secret is rotating. The timestamp header is in seconds and the documented replay tolerance is 5 minutes.

GitHub's endpoint is POST /repos/{owner}/{repo}/dispatches. It takes an event_type of at most 100 characters and an optional client_payload with no more than 10 top-level properties and under 65,535 characters, and it answers 204 with no body on success. Cloudflare's Web Crypto page lists HMAC for signing and verifying, and adds a non-standard crypto.subtle.timingSafeEqual for comparing digests.

Constraints the bridge has to respect (read 2026-10-03)
ConstraintValueHandling in the Worker
Sume replay window5 minutes, timestamp in secondsReject older or future timestamps
Sume signature headersume-v1=hex, possibly several entriesAccept if any entry matches
repository_dispatch event_type100 characters at mostFixed string such as sume-job
client_payload10 top-level properties, under 65,535 charactersSend job_id, event and url only
GitHub success204 No ContentTreat anything else as a failure and return 502

The Worker

Set SUME_COM_WEBHOOK_SIGNING_SECRET and GH_TOKEN as Worker secrets. The token needs permission to dispatch to the target repository; check the GitHub page for the scope your token type requires. An empty signing secret is refused outright.

export default {
  async fetch(req, env) {
    const secret = env.SUME_COM_WEBHOOK_SIGNING_SECRET;
    const ts = req.headers.get("x-sume-webhook-timestamp") ?? "";
    const sigs = (req.headers.get("x-sume-webhook-signature") ?? "").split(",");
    const raw = await req.text();
    if (!secret || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return new Response("no", { status: 401 });
    const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret),
      { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
    const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${ts}.${raw}`)));
    const want = "sume-v1=" + [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
    const ok = sigs.some((s) => s.trim().length === want.length &&
      crypto.subtle.timingSafeEqual(new TextEncoder().encode(s.trim()), new TextEncoder().encode(want)));
    if (!ok) return new Response("bad signature", { status: 401 });
    const evt = JSON.parse(raw);
    const gh = await fetch(`https://api.github.com/repos/${env.GH_REPO}/dispatches`, {
      method: "POST",
      headers: { Authorization: `Bearer ${env.GH_TOKEN}`, "User-Agent": "sume-bridge",
                 Accept: "application/vnd.github+json" },
      body: JSON.stringify({ event_type: "sume-job", client_payload: {
        job_id: evt.job_id, event: evt.event, url: evt.payload?.artifacts?.[0]?.url } }),
    });
    return new Response(null, { status: gh.status === 204 ? 200 : 502 });
  },
};

Make the workflow safe to run twice

Sume redelivers when your Worker returns non-2xx, up to 10 attempts, and a manual redeliver is always available, so the same job id can arrive more than once. Start the workflow with a concurrency group named after github.event.client_payload.job_id and make its first step skip if the artifact is already published. The Worker stays stateless; the dedupe lives where the side effect does.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume