# 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)

```javascript
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sevalla-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sevalla-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sevalla-webhooks/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

| 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

```bash
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):

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

```

Use the printed URL as the webhook endpoint in Sevalla.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/sevalla-webhooks/references/overview.md) - What Sevalla webhooks are, events and payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/sevalla-webhooks/references/setup.md) - Create the webhook, copy the secret, roll it
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/sevalla-webhooks/references/verification.md) - Header check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sevalla-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests