Webhooks

Signed, retryable HTTP POSTs delivered to your endpoint when something happens in your storage app.

Events

NameTypeDescription
upload.completedeventFired 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

  1. Open the app's Settings tab.
  2. Set webhookUrl to your public endpoint.
  3. 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.

JSON parsing changes whitespace, which breaks HMAC verification. Capture the raw request body before parsing.

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.

server/api/webhooks/storage.post.ts
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' })
  }
})
NameTypeRequiredDescription
bodystring | BufferyesRaw request body, exactly as received.
signaturestringyesX-Sonuslab-Signature header value.
secretstringyesSigning secret from the app settings. Must be non-empty — an empty string throws.
toleranceSecondsnumbernoMax age of the signature timestamp. Default 300.

Retry policy

AttemptDelayDescription
1immediateInitial delivery.
2+1sFirst retry, 1 second later.
3+5sSecond retry, 5 seconds after attempt 2.
4+30sFinal 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.

Copyright © 2026