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_ 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-signatureheader? - Does Sevalla sign webhook payloads? (No: the header is the shared secret itself)
- How do I handle
APP_DEPLOYorSTATIC_SITE_DEPLOYevents? - 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 thewhsec_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_ for that.
Event Types
| Type | Sent When |
|---|---|
APP_CREATE | An application is created |
APP_DEPLOY | An application's deployment starts (status inProgress) and again when it ends (success, failed, cancelled) |
APP_UPDATE | An application's settings are changed in the dashboard (not via the API) |
APP_DELETE | An application is deleted |
STATIC_SITE_CREATE | A static site is created |
STATIC_SITE_DEPLOY | A static site's deployment starts and again when it ends |
STATIC_SITE_UPDATE | A static site's settings are updated |
STATIC_SITE_DELETE | A static site is deleted |
DATABASE_CREATE, DATABASE_UPDATE, DATABASE_DELETE | Selectable, 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
- references/overview.md - What Sevalla webhooks are, events and payload shape
- references/setup.md - Create the webhook, copy the secret, roll it
- references/verification.md - Header check details, gotchas, debugging 401s
- examples/ - Runnable Express, Next.js and FastAPI handlers with tests