Avatar job webhooks during secret rotation: accept either signature

During a Sume secret rotation the signature header carries two sume-v1 entries. Accept the delivery if any one matches; Node verifier code included.

5 min readSume
All posts

When a Sume webhook signing secret is being rotated, the x-sume-webhook-signature header carries one sume-v1= entry per live secret, newest first, separated by commas. A correct verifier accepts the delivery when any entry matches.

This is stated on the Webhooks page, read 2026-10-02. The same scheme covers avatar creation and talking-video jobs, which deliver job.completed, job.failed and job.canceled.

What does the header look like in a rotation?

Outside a rotation there is one entry. During one, there are two. The signed message is <timestamp>.<raw_body>, HMAC SHA 256, hex encoded, prefixed with sume-v1=.

x-sume-webhook-timestamp: 1780000000
x-sume-webhook-signature: sume-v1=<new_hex>,sume-v1=<previous_hex>

A verifier that compares the whole header string to one expected value will start failing mid-rotation even though nothing is wrong with the delivery. That is the bug this post exists to prevent.

What should the verifier do?

Split on commas, ignore entries that do not start with sume-v1=, and compare each with a constant-time check. Refuse an empty secret so a missing environment variable fails closed instead of signing with nothing. Reject timestamps outside a replay window; the docs suggest five minutes.

import crypto from "node:crypto";

export function verify(raw: string, ts: string, header: string, secret: string) {
  if (!secret) return false;
  const t = Number(ts);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const hex = crypto.createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
  const want = Buffer.from(`sume-v1=${hex}`);
  let ok = false;
  for (const part of header.split(",")) {
    const got = Buffer.from(part.trim());
    if (got.length === want.length && crypto.timingSafeEqual(got, want)) ok = true;
  }
  return ok;
}

Where do I get the secret, and what if it does not verify?

Read it on the Webhooks tab of the dashboard (Reveal, then copy) or from GET /v1/webhooks/signing-secret with any API key carrying account:read. Sume's docs name the variable SUME_COM_WEBHOOK_SIGNING_SECRET, the same name the delivery worker signs with.

If a signature fails, compare x-sume-webhook-secret-fingerprint on the delivery with the fingerprint shown beside the secret in the dashboard. Neither side has to send the secret itself.

What else should an avatar webhook handler do?

Return any 2xx after storing the event. Delivery makes up to 10 attempts, with a fixed delay (30 seconds by default) and a 10 second timeout per attempt. Use job_id as your own idempotency key, because a redeliver re-sends the real terminal event with a fresh timestamp and signature.

Keep polling status_url as a backup; ten refused attempts leave a failed delivery and a job that still finished. Use Send test on /dashboard/webhooks to check your endpoint: it sends a dummy webhook.test payload and never replays a real job.

How do I test rotation handling without waiting for one?

Write a unit test that signs the same body with two secrets and builds a header with both entries joined by a comma. Assert that your verifier accepts it when your configured secret is either one, and rejects it when neither matches. Add a third case with an empty secret and assert it returns false.

Then add a stale-timestamp case, sent six minutes in the past, to confirm the replay window works. These four cases cover the logic that matters; none needs a network call.

Verify against the raw request body, not a parsed and re-serialized one. A JSON parser can change whitespace and key order, and the signature covers the exact bytes Sume sent. In frameworks that parse JSON automatically, capture the raw body first.

What does the avatar payload contain?

The terminal payload carries artifacts, each with an id, a url on media.sume.com, a type and a content_type. Sume mirrors outputs into Sume-owned URLs before exposing them, so store the Sume URL rather than any provider URL.

Failed and canceled deliveries use status: "ERROR" and include an error object. Branch on event, not on guesses about the body, and answer every event with a 2xx once stored.

Checklist for a rotation-safe receiver

Run through these before you rotate a secret.

  • Accept any one matching sume-v1= entry in a comma-separated header.
  • Fail closed when the configured secret is empty.
  • Verify the raw body and a timestamp within five minutes.
  • Answer 2xx only after the event is durably stored.
  • Use job_id as your idempotency key for redelivered events.
  • Keep status polling as a backup for deliveries that never arrive.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume