Agent skill

Sevalla Webhooks Skill

Receive and authenticate Sevalla webhooks. Use when setting up a Sevalla webhook endpoint, checking the svl-signature header, debugging 401s after rolling the secret, or handling app and static site events like APP_DEPLOY, APP_CREATE, APP_DELETE and STATIC_SITE_DEPLOY.

Install this skill

npx skills add hookdeck/webhook-skills --skill sevalla-webhooks


Sevalla (app, static site and database hosting) sends a JSON POST to your endpoint when an application or static site is created, updated, deployed or deleted. The envelope is {webhook_id, event_id, event_delivery_id, type, data} and type is upper snake case, for example APP_DEPLOY.

Sevalla requests are not signed. Despite its name and the whsec_ prefix, the svl-signature header is not a signature and is not Standard Webhooks. As Sevalla puts it, the header "contains the webhook's signing secret itself, not a hash of the body". There is no HMAC to compute, so do not use the svix or standardwebhooks libraries. You authenticate a request by comparing the header value with the secret you stored, in constant time.

When to Use This Skill

  • How do I receive Sevalla webhooks?
  • How do I verify the svl-signature header?
  • Does Sevalla sign webhook payloads? (No: the header is the shared secret itself)
  • How do I handle APP_DEPLOY or STATIC_SITE_DEPLOY events?
  • Why does my Sevalla webhook endpoint return 401 after I rolled the secret?

Verification (core)

const crypto = require('crypto');

// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
  const ha = crypto.createHash('sha256').update(String(a)).digest();
  const hb = crypto.createHash('sha256').update(String(b)).digest();
  return crypto.timingSafeEqual(ha, hb);
}

function verifySevallaRequest(headers, secrets = [process.env.SEVALLA_WEBHOOK_SECRET, process.env.SEVALLA_WEBHOOK_SECRET_OLD]) {
  const received = headers['svl-signature'];           // the WHOLE value is the secret
  if (typeof received !== 'string') return false;
  return secrets.filter(Boolean).some((secret) => safeEqual(received, secret));
}
  • Header: svl-signature: whsec_... (whsec_ followed by 30 characters, sent as plain text). Compare the whole value, including the whsec_ prefix. Header names are case-insensitive.
  • Copy the secret from the webhook's Overview tab in Sevalla (Signing secret).
  • Missing header or mismatch: 401. Body that is not a JSON object: 400. Handled: 200.
  • Read the body raw (express.raw({ type: '*/*' })) so any content type works, check the header, then parse.
  • A static shared credential authenticates the sender, not the body. It gives no replay protection (no signature, no timestamp) and anyone who sees one request can reuse the secret. Use HTTPS only and roll the secret if it leaks.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Hookdeck Setup Note

If you receive Sevalla webhooks through the Hookdeck Event Gateway (source type Sevalla):

Paste the whole secret, including the whsec_ prefix. When you roll the secret, update it here when the new one becomes active.

When you roll the secret in Sevalla, deliveries keep carrying the old secret until the activation time you chose, then the new one. Update the stored value at that moment. Sevalla also suggests accepting both secrets during the rollover; the examples support an optional SEVALLA_WEBHOOK_SECRET_OLD for that.

Event Types

TypeSent When
APP_CREATEAn application is created
APP_DEPLOYAn application's deployment starts (status inProgress) and again when it ends (success, failed, cancelled)
APP_UPDATEAn application's settings are changed in the dashboard (not via the API)
APP_DELETEAn application is deleted
STATIC_SITE_CREATEA static site is created
STATIC_SITE_DEPLOYA static site's deployment starts and again when it ends
STATIC_SITE_UPDATEA static site's settings are updated
STATIC_SITE_DELETEA static site is deleted
DATABASE_CREATE, DATABASE_UPDATE, DATABASE_DELETESelectable, but Sevalla doesn't send them yet

Dispatch on type. Deduplicate on event_id, which is the same on every retry (event_delivery_id changes per attempt). Any 2xx is success; failures are retried up to 10 times over about 2 days and 8 hours.

Environment Variables

SEVALLA_WEBHOOK_SECRET=whsec_your_signing_secret   # whole value, including whsec_
SEVALLA_WEBHOOK_SECRET_OLD=                        # optional: only while rolling the secret

Local Development

Sevalla can't reach localhost (and rejects private addresses). Tunnel with the Hookdeck CLI (no account required):

npx hookdeck-cli listen 3000 sevalla --path /webhooks/sevalla

Use the printed URL as the webhook endpoint in Sevalla.

Resources


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →