n8n 2.41.6 task runner and a Sume webhook verifier that never throws

n8n 2.41.6 keeps its task runner alive on unhandled rejections. Write the Sume webhook check so a bad signature returns false; answer 2xx only after storing.

4 min readSume
All posts

Write the signature check for a Sume webhook as a function that returns true or false and never throws, and let the workflow decide what a false means. The n8n releases page lists 2.41.6 as stable and says it keeps the task runner alive on an unhandled rejection, which is a good safety net but a poor reason to let a malformed header raise an exception.

A webhook receiver sees hostile and broken input as a matter of course: missing headers, empty bodies, a timestamp that is not a number. The verifier should absorb all of it.

What the release page lists

Relevant items only.

n8n releases (read 2026-10-03)
ReleaseItem
2.41.6 stableTask runner stays alive on unhandled rejection
2.42.0Improved OpenTelemetry export
2.42.0API workflow creation
2.42.0Agent builder layout changes

What Sume sends

A Sume job webhook is sent in mode: "webhook" with a public HTTPS webhook_url. It carries one of three terminal events: job.completed, job.failed or job.canceled. The body is signed with HMAC SHA 256 over <timestamp>.<raw_body>, and the headers are x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>.

During a signing-secret rotation the signature header holds one sume-v1= entry per live secret, comma separated. Accept the delivery when any entry matches.

A verifier that returns false

The check below refuses an empty secret, rejects a bad timestamp and a stale one, and compares every entry. It never raises on header shape, so the only thing the surrounding workflow has to branch on is a boolean.

import crypto from "node:crypto";

export function verifySume(rawBody, timestamp, header, secret, tolerance = 300) {
  if (!secret || typeof header !== "string") return false;
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > tolerance) return false;
  const digest = crypto.createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`).digest("hex");
  const expected = Buffer.from(`sume-v1=${digest}`);
  let ok = false;
  for (const entry of header.split(",")) {
    const got = Buffer.from(entry.trim());
    if (got.length === expected.length && crypto.timingSafeEqual(got, expected)) ok = true;
  }
  return ok;
}

Order of operations in the workflow

The shape of the receiving workflow matters more than the node types.

  • Capture the raw body exactly as received. Re-serialized JSON will not match the signature.
  • Run the verifier. On false, stop the branch and return a non-2xx so the sender knows delivery failed; do not process the payload.
  • Write the event to storage keyed by job_id, then answer 2xx. Sume retries on network errors and non-2xx responses.
  • Only after storing, fan out to Slack, storage or the next step.

Retries are bounded

Sume tries a delivery up to 10 times in total, with a fixed delay between attempts of 30 seconds by default and a 10 second timeout per attempt. A workflow that does slow work before answering burns that budget and gets the same event again.

Use job_id as the idempotency key on your side, so a repeated event is a no-op. Ten failed attempts leave a failed delivery and a job that still reached its terminal state, so keep status_url polling available, or ask Sume to redeliver with POST /v1/jobs/{job_id}/webhook/redeliver.

Tracing with 2.42

If you turn on the improved OpenTelemetry export that 2.42.0 lists, put the Sume job_id on the span that handles the event. It is the one identifier that ties a delivery, a poll and a usage row together.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume