> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sandywp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive a signed demo.ready callback whenever a visitor opens a demo from one of your public launch links.

When a visitor opens a demo from one of your [public launch links](/guides/templates), SandyWP can call your own endpoint the moment that sandbox is ready. Use it to push the lead into your CRM, start an onboarding email, or record the demo in your own dashboard.

## Set up the callback

<Steps>
  <Step title="Open Webhooks">
    Open the profile menu, select **Account**, and then choose the **Webhooks** tab.
  </Step>

  <Step title="Paste your callback URL">
    Enter the HTTPS endpoint SandyWP should POST to, leave **Send demo launch events** checked, and select **Save webhook**.
  </Step>

  <Step title="Store the signing secret">
    Copy the signing secret shown right after saving and add it to the receiving system. SandyWP shows it once and cannot show it again.
  </Step>
</Steps>

Select **Send test** to deliver a sample event immediately, without waiting for a visitor. The panel reports the result of the most recent delivery, so a failing endpoint is visible at a glance.

<Tip>
  While you are still exploring, paste a throwaway URL from a service such as webhook.site to watch a real payload arrive, then swap in your own endpoint.
</Tip>

The same callback can be managed from the [CLI](/cli#demo-launch-webhooks) with `sandywp webhook set`, or over HTTP with `PUT /api/account/webhooks/demo-launches`.

## What you receive

SandyWP sends one event type, `demo.ready`, once the launched sandbox is ready to serve its URL. The `email` field is `null` when the launch link does not collect an email.

```json theme={null}
{
  "id": "demo_ready_site_123",
  "type": "demo.ready",
  "occurredAt": "2026-08-02T12:00:02.000Z",
  "workspaceId": "workspace_123",
  "templateId": "template_123",
  "siteId": "site_123",
  "siteUrl": "https://demo.example.com/site-123",
  "email": "visitor@example.com",
  "status": "ready",
  "source": "template_launch"
}
```

Every request carries these headers.

| Header                | Purpose                                                     |
| --------------------- | ----------------------------------------------------------- |
| `x-sandywp-signature` | `sha256=` followed by the base64url HMAC of the raw body.   |
| `x-sandywp-event`     | The event type, currently always `demo.ready`.              |
| `x-sandywp-delivery`  | Unique id for this delivery attempt chain.                  |
| `idempotency-key`     | Stable across retries of the same event; deduplicate on it. |

Deliveries time out after ten seconds. A failing endpoint is retried with backoff and becomes terminal after eight attempts.

## Verify every request

Your callback URL is publicly reachable, so anyone who learns it could post fake events at it. That is what the signing secret is for: SandyWP signs each request with it, and your receiver recomputes the signature to confirm the request is genuine.

```js theme={null}
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

// express.raw() keeps the exact bytes SandyWP signed. Do not use express.json() here.
app.post('/sandywp/demo-ready', express.raw({ type: 'application/json' }), (req, res) => {
  const expected =
    'sha256=' +
    createHmac('sha256', process.env.SANDYWP_WEBHOOK_SECRET)
      .update(req.body)
      .digest('base64url');
  const received = req.get('x-sandywp-signature') ?? '';

  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on req.get('idempotency-key') before acting on the event.
  console.log(event.type, event.siteUrl);
  res.sendStatus(200);
});
```

<Warning>
  Three details are easy to get wrong: hash the **raw body bytes** before any JSON parsing, because re-serialized JSON will not match; the digest is **base64url**, not hex; and compare in constant time with `timingSafeEqual` rather than `===`.
</Warning>

Keep the secret in the receiving system's environment or secret manager, never in SandyWP and never in version control. If it is lost, select **Rotate secret** for a new value and update the receiver.

## Operating notes

* Respond with a `2xx` quickly and do slower work afterwards.
* Deduplicate on `idempotency-key`, since retries reuse the same value.
* Clear **Send demo launch events** to pause delivery while working on the endpoint. The saved URL is retained.

## Availability

Webhooks are available on every paid plan. In a shared Workspace, members need the integrations capability; the Workspace owner always holds it.

Create the launch link that produces these events in [Templates](/guides/templates), or manage the callback programmatically from the [API Reference](/api-reference).
