OpenRouter video callbacks vs Sume job webhooks: what differs

OpenRouter video callback_url reports completed, failed, cancelled, expired. Sume job webhooks send completed, failed, canceled, signed with HMAC-SHA256.

4 min readSume
All posts

Same idea, different edges

OpenRouter's video API is asynchronous: you POST to /api/v1/videos, get a polling_url, and check back; its guide suggests polling every 30 seconds and lists the statuses pending, in_progress, completed and failed. It also supports a callback_url that is notified when a job finishes, fails, is cancelled or expires.

Sume's /v1/videos route is shaped like that API and accepts callback_url too. If you are porting a callback handler, the differences are in the event set and the security details.

Comparison (read 2026-10-05)

OpenRouter video guide and Sume webhooks docs, read 2026-10-05
TopicOpenRouter videoSume job webhooks
Eventscompleted, failed, cancelled, expiredjob.completed, job.failed, job.canceled
Spelling of cancelcancelledcanceled (job events), cancelled on /v1/videos polling
SignatureSee the OpenRouter pageHMAC-SHA256 over timestamp.raw_body in x-sume-webhook-signature
Replay windowSee the OpenRouter page5 minutes on the timestamp
RetriesSee the OpenRouter page10 attempts, 30 s apart, 10 s timeout each

A verifier that refuses an empty secret

Sume signs sume-v1= plus the hex HMAC-SHA256 of the timestamp, a dot, and the raw body, so verify before parsing JSON. The header can carry several comma-separated entries while a secret is being rotated; accept any match.

import crypto from 'node:crypto';

export function verify(rawBody, headers, secret, now = Date.now()) {
  if (!secret) throw new Error('empty webhook secret');
  const ts = headers['x-sume-webhook-timestamp'];
  const sigs = String(headers['x-sume-webhook-signature'] || '').split(',');
  if (!ts || Math.abs(now / 1000 - Number(ts)) > 300) return false;
  const want = 'sume-v1=' + crypto.createHmac('sha256', secret)
    .update(ts + '.' + rawBody).digest('hex');
  return sigs.some((s) => {
    const a = Buffer.from(s.trim()), b = Buffer.from(want);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

Porting notes

An expired event has no counterpart in the Sume job webhook list, so a handler that waits for it needs no replacement: poll the polling_url or status_url for anything that never terminates. Dedupe on job_id, since Sume retries until you answer 2xx.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume