# Guide to Daytona Webhooks: Features and Best Practices

Daytona provides sandboxes for running AI-generated code, along with the snapshots and volumes behind them. Its webhooks tell you when a sandbox is created or changes state, when a snapshot finishes building, and when a volume is ready, so you can react without polling the API.

This guide covers how Daytona webhook endpoints work, the seven events and their payloads, how deliveries are signed and verified, and what to plan for given how little delivery behaviour Daytona documents.

## What are Daytona webhooks?

Daytona sends a JSON `POST` to each endpoint in your organisation that subscribes to an event. Endpoints are managed in the Daytona Dashboard under **Webhooks**, which is available to every member of the organisation. If you don't see it, Daytona asks you to contact support@daytona.io to enable it.

Daytona's webhooks page documents events and payloads but says nothing about signatures. Daytona's open-source webhook service shows that deliveries go out through Svix, so each request carries Svix's `svix-id`, `svix-timestamp` and `svix-signature` headers, signed with a `whsec_` endpoint secret. Because this comes from Daytona's code rather than its docs, check the headers on your first real delivery.

## Daytona webhook features

| Feature | Details |
| --- | --- |
| Configuration | Daytona Dashboard, **Webhooks** > **Add Endpoint** |
| Endpoint settings | Name, HTTPS URL and subscribed events |
| Event field | `event` in the body |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` (from Daytona's source, not its docs) |
| Scheme | `v1,<base64 HMAC-SHA256>` over `{svix-id}.{svix-timestamp}.{body}` |
| Secret | `whsec_` endpoint secret, base64-decoded after the prefix to form the key |
| Delivery logs | **Messages** tab, with each attempt's status code, duration and response body |
| Timeout, retries, ordering | Not documented by Daytona |
| Source IPs | Not documented |

## Common events

| Event | Fires when |
| --- | --- |
| `sandbox.created` | A sandbox is created |
| `sandbox.state.updated` | A sandbox changes state, for example from `started` to `stopped` |
| `snapshot.created` | A snapshot is created |
| `snapshot.state.updated` | A snapshot changes state, for example from `building` to `active` |
| `snapshot.removed` | A snapshot is removed |
| `volume.created` | A volume is created |
| `volume.state.updated` | A volume changes state, for example from `creating` to `ready` |

Payloads are flat. The two common fields, `event` and an ISO 8601 `timestamp`, sit next to the resource's own fields. Here is Daytona's `sandbox.state.updated` example:

```json
{
  "event": "sandbox.state.updated",
  "timestamp": "2025-12-19T10:30:00.000Z",
  "id": "sandbox123",
  "organizationId": "org123",
  "oldState": "started",
  "newState": "stopped",
  "updatedAt": "2025-12-19T10:30:00.000Z"
}

```

Created events carry `state` and `createdAt`. State events carry `oldState`, `newState` and `updatedAt`. `snapshot.removed` carries `removedAt`. Snapshot and volume events also include `name`, and `sandbox.created` adds `sandboxClass`.

The `id` field is the resource's ID, not an event ID. There is no event ID in the body, so use the `svix-id` header to deduplicate. The state values in the public docs are lower case, but Daytona's service README shows upper-case examples such as `STARTED`, so compare states case-insensitively.

## Setting up Daytona webhooks

1. In the Daytona Dashboard, click **Webhooks** in the sidebar, then **Add Endpoint**.
2. Enter a name, your HTTPS URL and the events you want, then click **Create**.
3. Open the endpoint's details page and copy its signing secret, which starts with `whsec_`. Daytona's docs don't describe the secret, so ask Daytona support if you can't find it.
4. Trigger an event, for example by creating a sandbox, and check the **Messages** tab. Each message lists its delivery attempts with the status code and response body your endpoint returned.

## Securing Daytona webhooks

Verification follows the Svix scheme:

* The signed content is `{svix-id}.{svix-timestamp}.{raw body}`.
* The key is the base64-decoded part of the secret after `whsec_`.
* `svix-signature` is a space-delimited list of `v1,<base64 signature>` entries. Accept the request if any of them match.

Svix's SDKs always enforce a five-minute timestamp window, which fails deliveries that a queue or replay tool sends on later. The manual version below makes that check optional and off by default:

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

// 0 skips the svix-timestamp check; set a positive number of seconds (for example 300) to enable it.
const TIMESTAMP_TOLERANCE_SECONDS = Number(process.env.DAYTONA_TIMESTAMP_TOLERANCE_SECONDS || 0);

function verifyDaytonaSignature(rawBody, headers, secret) {
  const msg_id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signature_header = headers['svix-signature'];
  if (!msg_id || !timestamp || !signature_header || !secret) return false;

  if (TIMESTAMP_TOLERANCE_SECONDS > 0) {
    const now = Math.floor(Date.now() / 1000);
    const sent_at = Number(timestamp);
    if (!Number.isFinite(sent_at) || Math.abs(now - sent_at) > TIMESTAMP_TOLERANCE_SECONDS) return false;
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const signed_content = Buffer.concat([Buffer.from(`${msg_id}.${timestamp}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', key).update(signed_content).digest('base64');
  const computed = Buffer.from(expected, 'utf8');

  return signature_header.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'utf8');
    return received.length === computed.length && crypto.timingSafeEqual(received, computed);
  });
}

const app = express();

app.post('/webhooks/daytona', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.DAYTONA_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyDaytonaSignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on req.get('svix-id'), route on event.event, then process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os
import time

TIMESTAMP_TOLERANCE_SECONDS = int(os.environ.get("DAYTONA_TIMESTAMP_TOLERANCE_SECONDS", "0"))

def verify_daytona_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header and secret):
        return False
    if TIMESTAMP_TOLERANCE_SECONDS > 0:
        if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TIMESTAMP_TOLERANCE_SECONDS:
            return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())
    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

```

> **Make Daytona webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Daytona's Svix signature, records every delivery, and lets you retry and replay sandbox, snapshot and volume events on your own schedule.

In Hookdeck, create a Daytona source and paste the endpoint's `whsec_` secret. Hookdeck checks the `svix-*` headers and also accepts the `webhook-*` names that Standard Webhooks uses. Verification is optional on the source, so you can leave it off while you confirm the headers on your first delivery.

## Daytona webhook limitations and pain points

### Signing isn't in Daytona's docs

**The Problem:** You have to work out how to verify deliveries from somewhere other than Daytona's documentation.

**Why It Happens:** Daytona's webhooks page covers events and payloads only. The Svix scheme comes from Daytona's open-source code and Svix's own documentation.

**Workarounds:**

* Log the headers of your first delivery and confirm the `svix-*` names before you enforce verification.
* Keep the secret per endpoint and rotate it if it leaks.

**How Hookdeck Can Help:** Hookdeck shows the full headers and body of each request, so you can confirm the scheme from a real delivery, then turn verification on for the source.

### No documented retry or timeout behaviour

**The Problem:** You can't tell how long Daytona will keep retrying a failed delivery, or how fast you need to respond.

**Why It Happens:** Daytona documents no timeout, retry schedule, ordering or disable policy. The **Messages** tab shows delivery attempts, but not the rules behind them.

**Workarounds:**

* Return `2xx` quickly and process asynchronously.
* Treat the Daytona API as the source of truth and reconcile sandbox state after an outage.

**How Hookdeck Can Help:** Hookdeck acknowledges Daytona's request at once, stores it, and applies a retry policy you control when delivering to your service.

### No event ID in the payload

**The Problem:** The body's `id` identifies the sandbox, snapshot or volume, so it repeats across many events.

**Why It Happens:** Daytona's payloads are flat resource summaries without an event identifier.

**Workarounds:**

* Deduplicate on `svix-id`, which stays the same when a message is retried.
* For state events, apply a change only if `updatedAt` is newer than what you have stored.

**How Hookdeck Can Help:** Hookdeck can deduplicate on a header such as `svix-id` before events reach your service.

## Best practices

### Deduplicate on `svix-id`

Store it before processing and return `200` for repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Order state changes by `updatedAt`

Daytona doesn't promise ordering. Track the latest `updatedAt` per resource and ignore older `sandbox.state.updated` events.

### Subscribe to what you need

`sandbox.state.updated` can be frequent if you start and stop many sandboxes. Subscribe each endpoint only to the events its consumer handles.

### Handle unknown event types

Daytona's API can send custom-typed messages to every endpoint in an organisation, so log and acknowledge events you don't recognise instead of returning an error.

## Conclusion

Daytona webhooks cover seven sandbox, snapshot and volume events with small, flat payloads. Signing isn't documented by Daytona, but deliveries go out through Svix, so verify `svix-signature` with the endpoint's `whsec_` secret, deduplicate on `svix-id`, and compare states case-insensitively.

[Hookdeck Event Gateway](https://hookdeck.com) verifies the signature and gives you the retry and replay policy that Daytona doesn't document.

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