Webhooks
Events
| Name | Type | Description |
|---|---|---|
upload.completed | event | Fired after a browser-finalized upload (the /complete call). Server-proxied uploads do not emit this event. |
Subscribe defensively — ignore unknown event types so new events don't break your handler.
Setup
- Open the app's Settings tab.
- Set
webhookUrlto your public endpoint. - Click Rotate secret to generate the signing secret. Copy it now — it's shown once.
Signature format
Each request carries an X-Sonuslab-Signature header. The format is Stripe-style:
X-Sonuslab-Signature: t=1718632456,v1=8b7c4f...
The signature is an HMAC-SHA256 over `${t}.${rawBody}` using your secret as the key.
verifyWebhook
verifyWebhook handles signature check, timestamp freshness, and JSON parse — throws on any failure. An empty or missing secret throws too, rather than deriving an HMAC from an empty key and accepting forged deliveries, so an unset env var fails loudly.
import { verifyWebhook } from 'sonuslab-storage/webhook'
export default defineEventHandler(async (event) => {
const body = await readRawBody(event)
const signature = getHeader(event, 'x-sonuslab-signature')!
const secret = process.env.SONUSLAB_WEBHOOK_SECRET!
try {
const payload = verifyWebhook({ body: body!, signature, secret })
switch (payload.event) {
case 'upload.completed':
await db.files.markUploaded(payload.data.file.id)
break
default:
// unknown event type — log + continue
}
return { ok: true }
} catch {
throw createError({ statusCode: 401, statusMessage: 'Invalid signature' })
}
})
| Name | Type | Required | Description |
|---|---|---|---|
body | string | Buffer | yes | Raw request body, exactly as received. |
signature | string | yes | X-Sonuslab-Signature header value. |
secret | string | yes | Signing secret from the app settings. Must be non-empty — an empty string throws. |
toleranceSeconds | number | no | Max age of the signature timestamp. Default 300. |
Retry policy
| Attempt | Delay | Description |
|---|---|---|
| 1 | immediate | Initial delivery. |
| 2 | +1s | First retry, 1 second later. |
| 3 | +5s | Second retry, 5 seconds after attempt 2. |
| 4 | +30s | Final retry, 30 seconds after attempt 3. |
Any 2xx response counts as success. Non-2xx (or timeout > 10s) triggers the next attempt. After the final attempt fails, the delivery is marked failed in the dashboard.
Idempotency
Delivery is at-least-once — retries mean your handler may receive the same event twice. Dedupe on payload.id, which is stable across every retry of the same event. Store it and reject duplicates before acting on the event.
const payload = verifyWebhook({ body: body!, signature, secret })
if (await db.webhookEvents.exists(payload.id)) return { ok: true, dedup: true }
await db.webhookEvents.insert({ id: payload.id })
// ...process event...
Manual retry
The app's Webhooks tab lists every delivery with status, response code, and a Retry button. Use it after fixing a bug in your handler.
