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.

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 | Cause | Handled by |
|---|---|---|
| Automatic retry | Your receiver timed out or returned non-2xx | Nats-Msg-Id inside the stream window |
| Redeliver | You asked Sume to re-send the real terminal event | Consumer write keyed on job_id |
| Webhook plus poll | Your poller also saw the terminal status | Consumer write keyed on job_id |
| Send test | webhook.test, no job_id | Skip: 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
2xxtells Sume to stop. - Read the raw body before any JSON parsing. The HMAC covers
<timestamp>.<raw_body>. - Keep the
status_urlpoll as a backup. A delivery can fail ten times while the job still reached its real terminal state. webhook.testpayloads have nojob_id. The sample falls back torequest_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
- Next.js ImageResponse RCE fix: safe share card from a Sume job
Next.js 16.3.6 patched a critical ImageResponse RCE. After upgrading, build a share-card route that reads the Sume artifact server-side, not from the URL.
- Next.js 16.3.8 dev-server MCP disclosure and where the Sume key lives
Next.js 16.3.8 fixes a low-severity dev-server MCP disclosure and a high-severity image SSRF. How to keep a Sume API key server-side as you upgrade.
- Next.js 16.3.8 fixes ISR cache poisoning: keep job pages dynamic
Next.js v16.3.8 fixes cache poisoning in SSG and ISR and Draft Mode leaks. Why a Sume job status page should stay dynamic and uncached, whatever the version.
- Next.js 16.3.8 route handler: a GET-only Sume job status proxy
A Next.js route handler that proxies only GET /v1/jobs/{id}/status to Sume, validates the id and keeps the key server-side. Written for 16.3.8.
Written by Sume