Nuxt Nitro server route for an AI video webhook with readRawBody
A Nuxt server/api route calls readRawBody, checks Sume's sume-v1 HMAC and answers 401 on a mismatch before it queues the finished video download.

In a Nuxt server route (Nitro, built on h3), use readRawBody(event) to get the unparsed request body and getRequestHeader(event, name) for the signature headers. The h3 v1 docs say readRawBody returns a string by default and a Buffer if you pass a falsy encoding (h3 request utilities, read 2026-10-06). The string form is what a text HMAC wants.
This is the receiver for a Sume callback_url. Sume signs <timestamp>.<raw_body> with HMAC SHA-256 and sends sume-v1=<hex> in x-sume-webhook-signature (Sume webhooks guide, read 2026-10-06). Check h3's version: Nuxt 3 ships h3 v1, and later majors may rename helpers.
What does server/api/sume-hook.post.ts contain?
The .post.ts suffix limits the route to POST. createError with 401 stops the handler on a bad signature. The secret is read from the environment and an empty value is refused.
import { createHmac, timingSafeEqual } from 'node:crypto'
export default defineEventHandler(async (event) => {
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? ''
const ts = getRequestHeader(event, 'x-sume-webhook-timestamp') ?? ''
const sig = getRequestHeader(event, 'x-sume-webhook-signature') ?? ''
const raw = (await readRawBody(event)) ?? ''
const fresh = /^\d+$/.test(ts) && Math.abs(Date.now() / 1000 - Number(ts)) <= 300
const want = Buffer.from('sume-v1=' +
createHmac('sha256', secret).update(`${ts}.${raw}`).digest('hex'))
const ok = !!secret && fresh && sig.split(',').some((p) => {
const got = Buffer.from(p.trim())
return got.length === want.length && timingSafeEqual(got, want)
})
if (!ok) throw createError({ statusCode: 401 })
const body = JSON.parse(raw)
if (body.event === 'job.completed') {
// queue a download for body.job_id
}
return { received: true }
})Which events arrive for a video?
| event | status in the body | Meaning for a /v1/videos job |
|---|---|---|
| job.completed | OK | Output is ready; payload.artifacts lists it |
| job.failed | ERROR | No output; the hold is released |
| job.canceled | ERROR | Cancelled before generation started |
What about duplicates and missed deliveries?
Treat job_id as the key and make the handler idempotent. Keep a periodic status read as a safety net: the webhook is terminal-only, so a lost delivery would otherwise leave a clip marked running forever.
Sources
Related posts
More in Developers
- One API key for Seedance 2.5, Wan 3.0, Kling 3 and MiniMax H3
Do you need a ByteDance, Alibaba, Kuaishou and MiniMax account to call their video models? On Sume one key and one wallet cover all four. How it works.
- One 6-minute b-roll, twelve Shorts episodes: source_in offsets
Slice one imported b-roll into twelve non-repeating 30-second episode backgrounds with Timeline 1.0 source_in, and plan the whole season unbilled in Python.
- One Sume API key per service: what it isolates and what it does not
Sume request budgets are per key and reads and writes are already separate. A key per service isolates revocation and scope, not generation capacity.
- OpenAI images.generate to Sume /v1/images: field by field map
Move a gpt-image-1 images.generate call to Sume POST /v1/images: which fields carry over, which return 400, and why size becomes image_size. Python mapper.
Written by Sume