# Webhooks

Receive asynchronous event notifications from sandboxes via webhooks. Webhooks allow you to react to process events, sandbox lifecycle changes, and file modifications.

## Webhook Configuration

Configure webhooks when claiming a sandbox:

| Field | Type | Description |
|-------|------|-------------|
| `url` | string | Webhook endpoint URL (required) |
| `secret` | string | Shared secret for payload signing (optional) |
| `watch_dir` | string | Directory to monitor for file events (optional) |

<Callout variant="info">
Webhooks are configured per-sandbox at creation time. Each sandbox can have its own webhook configuration.
</Callout>

<Callout variant="success">
`secret` is optional. For quick debugging, you can leave it empty and use services like <DocLink href="https://webhook.site" newTab>webhook.site</DocLink> to inspect incoming events directly.
</Callout>

## Delivery Guarantees

Webhook requests may be delivered more than once, so consumers must deduplicate by `event_id`. Your endpoint should return a `2xx` status only after it has durably recorded the event. Retries are best-effort and do not guarantee that an unavailable endpoint eventually receives every event.

When a sandbox is created with a webhook, procd writes signed delivery records to a reserved outbox under `/var/lib/sandbox0/procd/webhook-outbox` before acknowledging the internal publish request, then dispatches them with backoff. The outbox is part of the persistent sandbox rootfs and remains scoped to the sandbox identity. Runtime outbox delivery requires running guest compute; it waits while compute is paused. Its retryable failures are transport errors, `429`, and `5xx`; other non-`2xx` responses are terminal. The manager deletion outbox below has its own retry policy.

Sandbox deletion has an additional manager-level path. When manager commits a
sandbox deletion, it writes the exact signed `sandbox.deleted` request to a
PostgreSQL outbox in the same transaction. Runtime and RootFS cleanup never
wait for the external endpoint. Manager retries transport errors and HTTP
`408`, `425`, `429`, and `5xx` responses with exponential backoff for up to 24
hours. Other non-`2xx` responses are terminal. After the 24-hour delivery
window expires, manager stops retrying and later removes the bounded outbox
record.

<Callout variant="info">
Webhook delivery is eventually consistent. Temporary endpoint outages, procd container restarts, and manager restarts can cause retries. A response can be delivered more than once when the receiver accepts it but manager cannot durably record the success.
</Callout>

---

## Event Types

### Sandbox Events

| Event | Description |
|-------|-------------|
| `sandbox.ready` | Sandbox has been initialized and is ready |
| `sandbox.paused` | Process-local pause notification; not a guaranteed Nomad lifecycle event (see below) |
| `sandbox.resumed` | Process-local resume notification; not a guaranteed Nomad lifecycle event (see below) |
| `sandbox.killed` | Sandbox has been killed |
| `sandbox.deleted` | Manager has committed sandbox deletion and queued the final notification |

### Process Events

These notifications come from procd Context processes. They do not observe every workload subprocess; use the [session journal](/docs/sandbox/session-supervisor) for supervised session attempts.

| Event | Description |
|-------|-------------|
| `process.started` | A new process/context has started |
| `process.exited` | A process has exited (includes exit code) |
| `process.crashed` | A managed process ended in a crashed or killed state, including non-zero command exits |

### File Events

| Event | Description |
|-------|-------------|
| `file.modified` | A file event occurred (create, write, remove, rename, or chmod) |

<Callout variant="info">
File events require <code>watch_dir</code> and running guest compute. The filesystem operation is in <code>payload.event_type</code>; the envelope's <code>event_type</code> remains <code>file.modified</code>. Atomic file replacement can produce temporary-file and rename events. Offline paused-file edits do not run this watcher or create a replayable file-change history.
</Callout>

### Agent Events

| Event | Description |
|-------|-------------|
| `agent.event` | Custom event published by the sandbox via the webhook publish API |

---

## Configure Webhook at Sandbox Creation

Set up webhooks when claiming a sandbox.

<Tabs
  tabs={[
    {
      label: "Go",
      language: "go",
      code: `webhookURL := os.Getenv("SANDBOX0_WEBHOOK_URL")
webhookSecret := os.Getenv("SANDBOX0_WEBHOOK_SECRET")
if webhookURL == "" {
    log.Fatal("SANDBOX0_WEBHOOK_URL is required")
}

sandbox, err := client.ClaimSandbox(
    ctx,
    "default",
    sandbox0.WithSandboxHardTTL(300),
    sandbox0.WithSandboxWebhook(webhookURL, webhookSecret),
    sandbox0.WithSandboxWebhookWatchDir("/tmp/webhook-demo"),
)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Sandbox ID: %s\\n", sandbox.ID)`
    },
    {
      label: "Python",
      language: "python",
      code: `import os

from sandbox0.apispec.models.sandbox_config import SandboxConfig
from sandbox0.apispec.models.webhook_config import WebhookConfig

webhook_url = os.environ["SANDBOX0_WEBHOOK_URL"]
webhook_secret = os.environ.get("SANDBOX0_WEBHOOK_SECRET", "")

sandbox = client.claim_sandbox(
    template="default",
    config=SandboxConfig(
        hard_ttl=300,
        webhook=WebhookConfig(
            url=webhook_url,
            secret=webhook_secret,
            watch_dir="/tmp/webhook-demo",
        ),
    ),
)
print(f"Sandbox ID: {sandbox.id}")`
    },
    {
      label: "TypeScript",
      language: "typescript",
      code: `const webhookUrl = process.env.SANDBOX0_WEBHOOK_URL;
const webhookSecret = process.env.SANDBOX0_WEBHOOK_SECRET || '';
if (!webhookUrl) throw new Error('SANDBOX0_WEBHOOK_URL is required');

const sandbox = await client.sandboxes.claim('default', {
    hardTtl: 300,
    webhook: {
        url: webhookUrl,
        secret: webhookSecret,
        watchDir: '/tmp/webhook-demo',
    },
});
console.log('Sandbox ID:', sandbox.id);`
    },
    {
      label: "CLI",
      language: "bash",
      code: `# Replace the example endpoint and secret before using this file.
cat > sandbox-webhook.yaml <<'YAML'
template: default
config:
    hard_ttl: 300
    webhook:
        url: https://example.com/webhook
        secret: replace-with-your-shared-secret
        watch_dir: /tmp/webhook-demo
YAML
s0 sandbox create --config-file sandbox-webhook.yaml`
    }
  ]}
/>

---

## Signature Verification

When a secret is configured, webhook payloads are signed using HMAC-SHA256.

<Callout variant="info">
If `secret` is not configured, Sandbox0 will not send the `X-Sandbox0-Signature` header.
</Callout>

### Header

```
X-Sandbox0-Signature: <hex-encoded-signature>
```

The signature is the hex-encoded HMAC-SHA256 of the request body.

### Verification Example

The Python receiver uses Flask (`pip install Flask`); the TypeScript receiver uses Express (`npm install express`, plus `@types/express` for TypeScript).

These examples require a non-empty `SANDBOX0_WEBHOOK_SECRET` matching the sandbox configuration. They demonstrate signature checks; add durable event recording and `event_id` deduplication before returning success in your receiver.

<Tabs
  tabs={[
    {
      label: "Go",
      language: "go",
      code: `import (
    "io"
    "net/http"
    "os"

    sandbox0 "github.com/sandbox0-ai/sdk-go"
)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    // Verify against raw request bytes, not a re-serialized JSON object.
    payload, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read body failed", http.StatusBadRequest)
        return
    }
    signature := r.Header.Get("X-Sandbox0-Signature")
    secret := os.Getenv("SANDBOX0_WEBHOOK_SECRET")

    if secret == "" {
        http.Error(w, "webhook verification is not configured", http.StatusServiceUnavailable)
        return
    }
    if !sandbox0.VerifyWebhookSignature(secret, payload, signature) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    // Durably record and deduplicate event_id before returning success.
    w.WriteHeader(http.StatusOK)
}`
    },
    {
      label: "Python",
      language: "python",
      code: `import os
from flask import Flask, request
from sandbox0 import verify_webhook_signature

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    # Verify against raw request bytes, not a re-serialized JSON object.
    payload = request.get_data()
    signature = request.headers.get('X-Sandbox0-Signature', '')
    secret = os.environ.get('SANDBOX0_WEBHOOK_SECRET', '')
    if not secret:
        return 'Webhook verification is not configured', 503

    if not verify_webhook_signature(secret, payload, signature):
        return 'Invalid signature', 401

    event = request.json
    print(f"Received event: {event['event_type']}")
    return 'OK', 200`
    },
    {
      label: "TypeScript",
      language: "typescript",
      code: `import { verifyWebhookSignature } from 'sandbox0';
import express from 'express';

const app = express();

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    // Verify against raw request bytes, not a re-serialized JSON object.
    const payload = req.body as Buffer;
    const signature = (req.headers['x-sandbox0-signature'] as string) || '';
    const secret = process.env.SANDBOX0_WEBHOOK_SECRET;
    if (!secret) {
        res.status(503).send('Webhook verification is not configured');
        return;
    }

    if (!verifyWebhookSignature(secret, payload, signature)) {
        res.status(401).send('Invalid signature');
        return;
    }

    const event = JSON.parse(payload.toString('utf8'));
    console.log('Received event:', event.event_type);
    res.status(200).send('OK');
});`
    },
    {
      label: "CLI",
      language: "bash",
      code: `# Webhook server example using netcat (for testing)
# In production, use a proper HTTP server

while true; do
    echo -e "HTTP/1.1 200 OK\\r\\n\\r\\nOK" | nc -l -p 8080 -q 1
done`
    }
  ]}
/>

---

## Triggering Webhook Events

### Sandbox Events

`sandbox.ready` is emitted during runtime initialization. `sandbox.killed` is a best-effort graceful procd shutdown event, and `sandbox.deleted` is queued by manager after durable deletion.

The current Nomad lifecycle pauses by retiring compute and resumes by launching or restoring a runtime. It does not call the legacy process-local pause/resume handlers that emit `sandbox.paused` and `sandbox.resumed`. Do not treat those webhook names as guaranteed notifications of public pause/resume completion. Use committed sandbox details, lifecycle wait helpers, or an exact operation receipt when correctness depends on that transition.

```python
# Observe committed lifecycle state; these calls do not prove webhook delivery.
paused = client.sandboxes.pause_and_wait(sandbox.id)
print(paused.status)
running = client.sandboxes.resume_and_wait(sandbox.id)
print(running.status)
```

### Process Events

<Tabs
  tabs={[
    {
      label: "Go",
      language: "go",
      code: `// A one-shot command starts and exits, unlike a cached REPL.
_, err = sandbox.Cmd(ctx, \`echo "hello world"\`)
if err != nil {
    log.Fatal(err)
}
log.Println("Process events triggered")

// Non-zero process exit is result metadata, not an SDK request error.
result, err := sandbox.Cmd(ctx, \`/bin/sh -c "exit 2"\`)
if err != nil {
    log.Fatal(err)
}
if result.ExitCode != nil {
    log.Printf("Process exit code: %d", *result.ExitCode)
}`
    },
    {
      label: "Python",
      language: "python",
      code: `# A one-shot command starts and exits, unlike a cached REPL.
sandbox.cmd('echo "hello world"')
print("Process events triggered")

# Non-zero process exit is result metadata, not an SDK request error.
result = sandbox.cmd('/bin/sh -c "exit 2"')
print(f"Process exit code: {result.exit_code}")`
    },
    {
      label: "TypeScript",
      language: "typescript",
      code: `// A one-shot command starts and exits, unlike a cached REPL.
await sandbox.cmd('echo "hello world"');
console.log('Process events triggered');

// Non-zero process exit is result metadata, not an SDK request error.
const result = await sandbox.cmd('/bin/sh -c "exit 2"');
console.log('Process exit code:', result.exitCode);`
    },
    {
      label: "CLI",
      language: "bash",
      code: `# Trigger process events
s0 sandbox exec sb_abc123 -- echo "hello world"

# Trigger process with exit code
s0 sandbox exec sb_abc123 -- /bin/sh -c "exit 2"`
    }
  ]}
/>

### File Events

<Tabs
  tabs={[
    {
      label: "Go",
      language: "go",
      code: `// Ensure watch directory exists
_, err = sandbox.Mkdir(ctx, "/tmp/webhook-demo", true)
if err != nil {
    log.Fatal(err)
}

// Trigger file.modified events (with payload.event_type: "create", "write")
_, err = sandbox.WriteFile(ctx, "/tmp/webhook-demo/file.txt", []byte("hello"))
if err != nil {
    log.Fatal(err)
}
log.Println("File created/written (delivery is asynchronous)")

// Trigger file.modified event (with payload.event_type: "rename")
_, err = sandbox.MoveFile(ctx, "/tmp/webhook-demo/file.txt", "/tmp/webhook-demo/file-renamed.txt")
if err != nil {
    log.Fatal(err)
}
log.Println("File renamed (delivery is asynchronous)")

// Trigger file.modified event (with payload.event_type: "chmod")
_, err = sandbox.Cmd(ctx, \`/bin/sh -c "chmod 600 /tmp/webhook-demo/file-renamed.txt"\`)
if err != nil {
    log.Fatal(err)
}
log.Println("File chmodded (delivery is asynchronous)")

// Trigger file.modified event (with payload.event_type: "remove")
_, err = sandbox.DeleteFile(ctx, "/tmp/webhook-demo/file-renamed.txt")
if err != nil {
    log.Fatal(err)
}
log.Println("File removed (delivery is asynchronous)")`
    },
    {
      label: "Python",
      language: "python",
      code: `# Ensure watch directory exists
sandbox.mkdir("/tmp/webhook-demo", recursive=True)

# Trigger file.modified events (with payload.event_type: "create", "write")
sandbox.write_file("/tmp/webhook-demo/file.txt", b"hello")
print("File created/written (delivery is asynchronous)")

# Trigger file.modified event (with payload.event_type: "rename")
sandbox.move_file("/tmp/webhook-demo/file.txt", "/tmp/webhook-demo/file-renamed.txt")
print("File renamed (delivery is asynchronous)")

# Trigger file.modified event (with payload.event_type: "chmod")
sandbox.cmd('/bin/sh -c "chmod 600 /tmp/webhook-demo/file-renamed.txt"')
print("File chmodded (delivery is asynchronous)")

# Trigger file.modified event (with payload.event_type: "remove")
sandbox.delete_file("/tmp/webhook-demo/file-renamed.txt")
print("File removed (delivery is asynchronous)")`
    },
    {
      label: "TypeScript",
      language: "typescript",
      code: `// Ensure watch directory exists
await sandbox.mkdir('/tmp/webhook-demo', true);

// Trigger file.modified events (with payload.event_type: "create", "write")
await sandbox.writeFile('/tmp/webhook-demo/file.txt', 'hello');
console.log('File created/written (delivery is asynchronous)');

// Trigger file.modified event (with payload.event_type: "rename")
await sandbox.moveFile('/tmp/webhook-demo/file.txt', '/tmp/webhook-demo/file-renamed.txt');
console.log('File renamed (delivery is asynchronous)');

// Trigger file.modified event (with payload.event_type: "chmod")
await sandbox.cmd('/bin/sh -c "chmod 600 /tmp/webhook-demo/file-renamed.txt"');
console.log('File chmodded (delivery is asynchronous)');

// Trigger file.modified event (with payload.event_type: "remove")
await sandbox.deleteFile('/tmp/webhook-demo/file-renamed.txt');
console.log('File removed (delivery is asynchronous)');`
    },
    {
      label: "CLI",
      language: "bash",
      code: `# Create watch directory
s0 sandbox files mkdir -s sb_abc123 /tmp/webhook-demo --parents

# Trigger file.modified events
s0 sandbox files write -s sb_abc123 /tmp/webhook-demo/file.txt --data "hello"
s0 sandbox files mv -s sb_abc123 /tmp/webhook-demo/file.txt /tmp/webhook-demo/file-renamed.txt
s0 sandbox exec sb_abc123 -- chmod 600 /tmp/webhook-demo/file-renamed.txt
s0 sandbox files rm -s sb_abc123 /tmp/webhook-demo/file-renamed.txt`
    }
  ]}
/>

### Agent Events

Publish custom events from within the sandbox using the webhook publish API:

<Tabs
  tabs={[
    {
      label: "Go",
      language: "go",
      code: `// Publish a custom agent event from within the sandbox
_, err = sandbox.Run(ctx, "bash", \`curl -X POST http://localhost:49983/api/v1/webhook/publish \\
  -H "Content-Type: application/json" \\
  -d '{"payload": {"message": "Task completed", "progress": 100}}'\`)
if err != nil {
    log.Fatal(err)
}
log.Println("Agent event published")`
    },
    {
      label: "Python",
      language: "python",
      code: `# Publish a custom agent event from within the sandbox
sandbox.run(
    "bash",
    """curl -X POST http://localhost:49983/api/v1/webhook/publish \\
    -H "Content-Type: application/json" \\
    -d '{"payload": {"message": "Task completed", "progress": 100}}'"""
)
print("Agent event published")`
    },
    {
      label: "TypeScript",
      language: "typescript",
      code: `// Publish a custom agent event from within the sandbox
await sandbox.run('bash', \`curl -X POST http://localhost:49983/api/v1/webhook/publish \\
    -H "Content-Type: application/json" \\
    -d '{"payload": {"message": "Task completed", "progress": 100}}'\`);
console.log('Agent event published');`
    },
    {
      label: "CLI",
      language: "bash",
      code: `# Publish a custom agent event from within the sandbox
# Using curl inside the sandbox
s0 sandbox exec sb_abc123 -- curl -X POST http://localhost:49983/api/v1/webhook/publish \\
    -H "Content-Type: application/json" \\
    -d '{"payload": {"message": "Task completed", "progress": 100}}'`
    }
  ]}
/>

---

## Next Steps

<CardGroup>
  <Card title="Sandbox Functions" href="/docs/sandbox/functions" cta="Continue">
    Build public webhook receivers without running long-lived HTTP listeners.
  </Card>

  <Card title="Protocol Controls" href="/docs/sandbox/protocol-controls" cta="Continue">
    Enforce egress and protocol policy for sandbox traffic.
  </Card>
</CardGroup>
