NATS JetStream: use job_id as Nats-Msg-Id for Sume webhooks

Verify the sume-v1 signature, then publish the raw event to JetStream with job_id as Nats-Msg-Id. Node receiver sample, plus why the consumer must still dedupe.

5 min readSume
All posts

Verify the sume-v1 signature on the raw body, then publish the event to JetStream with the header Nats-Msg-Id set to the event's job_id. Return 204 only after the publish is acknowledged. Sume retries any non-2xx response, and the repeat publish carries the same message id, so the stream can discard it.

That covers retries that arrive close together. It does not cover a Redeliver that you trigger days later, which sends the real terminal event again with a fresh timestamp and signature. Treat the message id as a first filter, and keep an idempotent write keyed on job_id in the consumer.

A receiver that publishes after verifying

The receiver below is plain Node with the nats client. It refuses an empty secret, checks the timestamp against a five-minute tolerance, and compares every sume-v1= entry in the header, so it also works while a signing secret is rotating. The stream (subjects sume.jobs.>) is assumed to exist already.

import { connect, headers } from "nats";
import crypto from "node:crypto";
import http from "node:http";
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");
const js = (await connect({ servers: process.env.NATS_URL ?? "nats://localhost:4222" })).jetstream();
function valid(raw, ts, header) {
  if (!(Math.abs(Date.now() / 1000 - Number(ts)) <= 300)) return false;
  const mac = crypto.createHmac("sha256", secret).update(`${ts}.${raw}`).digest("hex");
  const want = Buffer.from(`sume-v1=${mac}`);
  return header.split(",").some((e) => {
    const got = Buffer.from(e.trim());
    return got.length === want.length && crypto.timingSafeEqual(got, want);
  });
}
http.createServer(async (req, res) => {
  const chunks = [];
  for await (const c of req) chunks.push(c);
  const raw = Buffer.concat(chunks).toString("utf8");
  const sig = String(req.headers["x-sume-webhook-signature"] ?? "");
  if (!valid(raw, req.headers["x-sume-webhook-timestamp"], sig)) return res.writeHead(401).end();
  const event = JSON.parse(raw);
  const h = headers();
  h.set("Nats-Msg-Id", event.job_id ?? event.request_id);
  await js.publish("sume.jobs.terminal", Buffer.from(raw), { headers: h });
  res.writeHead(204).end();
}).listen(8080);

What dedupes what

Sume sends up to 10 attempts at a fixed spacing, with a 10 second timeout on each. A JetStream duplicate window is a time window set on the stream, so size it knowing how your delivery attempts are spread, and do not assume it covers a manual Redeliver.

Duplicate sources and the layer that handles each (Sume docs, read 2026-10-04)
DuplicateCauseHandled by
Automatic retryYour receiver timed out or returned non-2xxNats-Msg-Id inside the stream window
RedeliverYou asked Sume to re-send the real terminal eventConsumer write keyed on job_id
Webhook plus pollYour poller also saw the terminal statusConsumer write keyed on job_id
Send testwebhook.test, no job_idSkip: no job to record

Checks before you rely on it

  • Publish before you respond. If the publish fails, return a 5xx so Sume retries; a 2xx tells Sume to stop.
  • Read the raw body before any JSON parsing. The HMAC covers <timestamp>.<raw_body>.
  • Keep the status_url poll as a backup. A delivery can fail ten times while the job still reached its real terminal state.
  • webhook.test payloads have no job_id. The sample falls back to request_id, which is fine for a test, and a real consumer should ignore the event.

Keep the handler short, and keep failures on the stream

Do not move work into the HTTP handler to make the publish faster. The handler has one job: prove the body came from Sume, put it on the stream, and answer. Everything else, such as downloading the artifact URLs from payload.artifacts or telling a user their clip is ready, belongs to a consumer that reads the stream at its own pace and can be restarted without losing events.

Failed and canceled jobs arrive on the same subject as completed ones, with status: "ERROR" and an error object. A consumer that only handles job.completed will leave a failed render looking pending forever, so branch on event and write the failure down.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume