# Guide to Sevalla Webhooks: Features and Best Practices

Sevalla webhooks tell your systems what is happening on your hosting platform: an application or static site is created, updated or deleted, and above all, a deployment starts and finishes. If you deploy on Sevalla, webhooks let you post deploy notifications to chat, run tests after a deployment, or keep another system in sync without polling the Sevalla API.

This guide covers how Sevalla webhooks are created, the events and payload, what the `svl-signature` header really contains, how retries and secret rollover work, and the practices that keep a receiver reliable.

## What are Sevalla webhooks?

A Sevalla webhook sends an HTTP `POST` with a JSON body to your endpoint each time one of the events you picked happens anywhere in your company. A company can have up to 10 webhooks, and each covers every application and static site, so you can't limit one to a single service. Filter on the IDs in the payload instead.

Each webhook has a signing secret that starts with `whsec_`, and each request has an `svl-signature` header. Despite those names, nothing is signed. Sevalla's docs are explicit: "The `svl-signature` header contains the webhook's signing secret itself, not a hash of the body." Your receiver compares the header with the secret it stored. Libraries for Standard Webhooks or HMAC signatures won't work here.

## Sevalla webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Tools > Webhooks > Create webhook**, or the API and CLI |
| Permissions | **Company owner** or **Company admin**, and a valid payment method |
| Limit | 10 webhooks per company |
| Scope | Every application and static site in the company |
| Method | `POST`, `content-type: application/json` |
| Authentication | `svl-signature` header carrying the secret (`whsec_` plus 30 characters) |
| Signature | None. The header is the secret, not a hash |
| Success | Any `2xx` |
| Retries | Up to 10, from 1 minute to 1 day apart, within 4 days of the event |
| Ordering | Not guaranteed |
| Secret rotation | **Roll secret**, with the old secret kept for up to 24 hours |
| Delivery log | **Event deliveries** tab, with request and response for each attempt |

## Common events

| Event | Sent when |
| --- | --- |
| `APP_CREATE` | An application is created |
| `APP_DEPLOY` | A deployment starts (`status: "inProgress"`), and again when it ends (`success`, `failed` or `cancelled`) |
| `APP_UPDATE` | An application's name, default branch, automatic deployments, hibernation or project is changed in the dashboard |
| `APP_DELETE` | An application is deleted |
| `STATIC_SITE_CREATE`, `STATIC_SITE_UPDATE`, `STATIC_SITE_DELETE` | A static site is created, has its settings updated, or is deleted |
| `STATIC_SITE_DEPLOY` | A static site deployment starts, and again when it ends |
| `DATABASE_CREATE`, `DATABASE_UPDATE`, `DATABASE_DELETE` | Not sent at the moment |

Two of these behave unexpectedly. `APP_UPDATE` only fires for changes made in the dashboard: "Changes made through the API don't send it." And the database events can be selected but aren't sent yet, so a webhook subscribed only to them receives nothing.

Every body has the same envelope. Here is Sevalla's `APP_DEPLOY` example:

```json
{
  "webhook_id": "d5f8a1c2-3b4e-4f6a-8b8c-9d0e1f2a3b4c",
  "event_id": "a1b2c3d4-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
  "event_delivery_id": "f1e2d3c4-b5a6-4788-9a0b-c1d2e3f4a5b6",
  "type": "APP_DEPLOY",
  "data": {
    "company_id": "0c9e3f5a-7b2d-4e1f-8a6c-5d4b3a2f1e0d",
    "app_id": "7f3c2b1a-9e8d-4c7b-a6f5-4e3d2c1b0a9f",
    "app_name": "storefront-x7k2m",
    "app_display_name": "storefront",
    "deployment_id": "3b2a1c0d-8e7f-4a6b-9c5d-2e1f0a9b8c7d",
    "status": "success",
    "commit": "9fceb02d0ae598e95dc970b74767f19372d61af8",
    "stage": "",
    "is_preview": false
  }
}

```

`event_id` is the same on every retry, so use it to drop duplicates. `event_delivery_id` is new on every attempt. In deploy events, `commit` is absent or `null` for Docker image deployments, `stage` is the pipeline stage or an empty string, and `is_preview` is `true` for preview apps and pull request previews. In `APP_UPDATE`, `name` and `display_name` hold the values from before the update.

## Setting up Sevalla webhooks

You need the **Company owner** or **Company admin** role, and the company needs a valid payment method.

1. In the sidebar, under **Tools**, click **Webhooks**, then **Create webhook**.
2. Enter your HTTPS endpoint in **Endpoint**, and optionally a **Description**. Endpoints that resolve to private, loopback or link-local addresses are rejected.
3. Under **Allowed events**, tick the events to send.
4. Click **Create webhook**. It starts sending events straight away.
5. Click the webhook, and on its **Overview** tab under **Details**, copy the **Signing secret**. Store it server-side, for example as `SEVALLA_WEBHOOK_SECRET`.

From the CLI, `sevalla webhooks create --endpoint <url> --events APP_DEPLOY,STATIC_SITE_DEPLOY` does the same, and `sevalla webhooks get <id>` returns the secret.

**With Hookdeck.** Sevalla is a supported source in Hookdeck. Create a source with the Sevalla type, paste the whole secret into **Signing Secret**, including the `whsec_` prefix, and use the Hookdeck source URL as the webhook's endpoint. Hookdeck compares `svl-signature` with that value. Hookdeck's setup note says that when you roll the secret, you update it on the source when the new one becomes active. The source accepts `POST` only, which is the method Sevalla uses. A request without the right header still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing Sevalla webhooks

Compare `svl-signature` with your stored secret in constant time, and during a rollover accept both the old and the new secret, as Sevalla recommends. Check lengths before calling `crypto.timingSafeEqual`.

```javascript
const crypto = require('crypto');
const express = require('express');

// The current secret, and during a rollover the old one too.
const SECRETS = [process.env.SEVALLA_WEBHOOK_SECRET, process.env.SEVALLA_WEBHOOK_SECRET_OLD].filter(Boolean);

function isFromSevalla(header) {
  if (typeof header !== 'string' || SECRETS.length === 0) return false;
  const received = Buffer.from(header, 'utf8');
  return SECRETS.some((secret) => {
    const expected = Buffer.from(secret, 'utf8');
    return expected.length === received.length && crypto.timingSafeEqual(expected, received);
  });
}

const app = express();

app.post('/webhooks/sevalla', express.raw({ type: '*/*' }), (req, res) => {
  if (!isFromSevalla(req.get('svl-signature'))) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  res.sendStatus(200); // answer first, then do the work
  // Deduplicate on event.event_id, then route on event.type.
});

```

The same check in Python:

```python
import hmac
import os

SECRETS = [
    s
    for s in (os.environ.get("SEVALLA_WEBHOOK_SECRET"), os.environ.get("SEVALLA_WEBHOOK_SECRET_OLD"))
    if s
]

def is_from_sevalla(header) -> bool:
    if not header:
        return False
    received = header.encode("utf-8")
    return any(hmac.compare_digest(received, s.encode("utf-8")) for s in SECRETS)

```

Because the header is the secret itself, it proves the sender knows the secret but not that the body is unchanged, and it gives no replay protection: nothing is signed and there is no timestamp. Anyone who sees one request can reuse the secret. Use an `https://` endpoint, as Sevalla advises "because the signing secret travels in a header", keep the secret out of logs, and roll it if it might have leaked.

> **Make Sevalla webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks `svl-signature` on every Sevalla request, stores each deploy and application event, and lets you replay any of them, which Sevalla's dashboard can't.

## Sevalla webhook limitations and pain points

### A "signature" that isn't one

**The Problem:** HMAC or Standard Webhooks verification fails on every request.

**Why It Happens:** The `whsec_` prefix and the `svl-signature` name suggest a signature scheme, but the header carries the secret unchanged.

**Workarounds:**

* Compare the header with the secret as a plain string, in constant time.
* Don't decode, strip or hash the `whsec_` value.

**How Hookdeck Can Help:** Hookdeck's Sevalla source compares the header with the whole secret you paste, so you don't have to reimplement the check in every service.

### Rolling the secret has a cut-over moment

**The Problem:** After a rollover, requests start failing verification.

**Why It Happens:** **Roll secret** lets you keep the old secret on deliveries for **Now**, 1, 2, 4, 8, 16 or 24 hours (the default). "Deliveries keep carrying the old secret until that time, then the new one." Retries carry the current secret too, so a retry of an older event switches at the same moment.

**Workarounds:**

* Add the new secret to your receiver as soon as you roll, and keep the old one until the period ends, as in the code above.
* Avoid **Now** unless your receiver already has the new secret.

**How Hookdeck Can Help:** A Hookdeck source holds one value, so update **Signing Secret** at the moment the new secret becomes active, which the webhook's **Rolling over** note shows. Because Hookdeck answers `200` even to requests that fail verification, Sevalla won't retry those, so keep the gap short and check Hookdeck's request log afterwards.

### Retries stop after 4 days, and there is no resend button

**The Problem:** An event missed during a long outage is gone.

**Why It Happens:** Sevalla retries a failed event up to 10 times, at 1 minute, 5 minutes, 10 minutes, 30 minutes, 1, 2, 4, 8 and 16 hours, then 1 day, and only within 4 days of the event. "There's no button to resend an event by hand." Events that happen while a webhook is disabled aren't sent later either.

**Workarounds:**

* Answer `2xx` quickly so slow work doesn't count as a failure.
* After an outage, read deployment status from the Sevalla API.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as it arrives and stores it, then delivers to your service with [automatic retries](/docs/retries) and lets you replay any stored event. [Issues](/docs/issues) alert you when deliveries fail.

### Every service, every event

**The Problem:** A webhook meant for one application receives events from all of them.

**Why It Happens:** Webhooks are company-wide, and there are only 10 per company.

**Workarounds:**

* Filter on `app_id` or `static_site_id` in your handler.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on `data.app_id`, `type` or `data.status`, so one Sevalla webhook can feed several services, each seeing only its own events.

## Best practices

### Handle both deploy events

`APP_DEPLOY` and `STATIC_SITE_DEPLOY` fire at the start and the end of a deployment. Check `data.status` before announcing a successful release, and expect `inProgress` first.

### Deduplicate on `event_id`

Each retry has a new `event_delivery_id` but the same `event_id`. Store `event_id` before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't rely on order

Sevalla says ordering isn't guaranteed. A `success` can arrive before its `inProgress`, so keep the latest state per `deployment_id` rather than applying events in arrival order.

### Acknowledge fast and process asynchronously

Sevalla's advice is to respond with a `2xx` as soon as you've checked the header and "do slow work afterwards". See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Sevalla sends JSON `POST` requests for application and static site events, with the webhook's `whsec_` secret copied into `svl-signature`. Compare that header as a plain string in constant time over HTTPS, accept both secrets during a rollover, deduplicate on `event_id`, and handle both the start and end of each deployment.

[Hookdeck Event Gateway](https://hookdeck.com) checks `svl-signature`, stores every deploy event, and retries delivery to your service on your own schedule, with replay when you need it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Sevalla webhooks reliably in minutes.