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

# Webhooks

> Subscribe a URL to events your project cares about. Every delivery signed with HMAC-SHA256.

Webhooks are the alternative to polling. Subscribe an HTTPS URL on your side
to one or more event topics; we'll POST a signed JSON payload every time
something matters. Use them to rebuild a static site, fan out to
Slack/Discord, sync orders to your warehouse, or refresh a CDN cache.

## Setup in 60 seconds

<Steps>
  <Step title="Stand up an endpoint">
    Any HTTPS URL that responds 2xx within 30 seconds. Keep it cheap;
    do the heavy work asynchronously after acknowledging.

    ```ts theme={null}
    // app/api/neural-draft/route.ts (Next.js App Router)
    import { NextResponse } from "next/server";
    import { verifyNeuralDraftSignature } from "@/lib/neural-draft";

    export async function POST(req: Request) {
      const raw = await req.text();
      const signature = req.headers.get("x-neural-draft-signature") ?? "";
      const ok = verifyNeuralDraftSignature(raw, signature, process.env.NEURAL_DRAFT_WEBHOOK_SECRET!);
      if (!ok) return new NextResponse("invalid signature", { status: 401 });

      const event = JSON.parse(raw);
      // Acknowledge fast, do work async.
      queueMicrotask(() => handleEvent(event));
      return NextResponse.json({ received: true });
    }
    ```
  </Step>

  <Step title="Subscribe">
    Create the endpoint via API or dashboard. The response includes a
    `signing_secret` — store it; it's shown only once.

    ```bash theme={null}
    curl -X POST https://api.neuraldraft.io/v1/webhook-endpoints \
      -H "Authorization: Bearer $NEURALDRAFT_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://example.com/api/neural-draft",
        "events": ["order.paid", "order.refunded", "blog_post.published"]
      }'
    ```

    ```json theme={null}
    {
      "id": "whe_2NgcaXxFqLPo",
      "url": "https://example.com/api/neural-draft",
      "events": ["order.paid", "order.refunded", "blog_post.published"],
      "is_active": true,
      "signing_secret": "whsec_OmtQ9X5gTzUv7sR1kBhJnAeYpV2cDfWiM3oP",
      "created_at": "2026-04-19T10:14:22Z"
    }
    ```
  </Step>

  <Step title="Verify and act">
    On every delivery, recompute the HMAC and compare it constant-time. Reject
    requests with a stale timestamp (we recommend a 5-minute window).
  </Step>
</Steps>

## Event topics

| Topic                     | Fires when                                                           |
| ------------------------- | -------------------------------------------------------------------- |
| `blog_post.published`     | A post transitions to `status=published`, manually or via scheduler. |
| `blog_post.translated`    | A post translation finishes (per language).                          |
| `social_post.published`   | A scheduled or on-demand publish succeeded on at least one platform. |
| `social_post.failed`      | All platforms in a post returned an error.                           |
| `order.created`           | A new `Order` row is created (manual or Stripe Checkout).            |
| `order.paid`              | An `Order` transitions to `payment_status=paid`.                     |
| `order.fulfilled`         | An `Order` transitions to `status=delivered`.                        |
| `order.cancelled`         | An `Order` transitions to `status=cancelled`.                        |
| `order.refunded`          | A full or partial refund completes.                                  |
| `booking.confirmed`       | A booking is confirmed (auto or manual).                             |
| `booking.cancelled`       | A booking is cancelled (admin or customer).                          |
| `booking.completed`       | A booking is marked complete after the appointment.                  |
| `content.changed`         | A translation key value changes — useful for SSG rebuild triggers.   |
| `image.generated`         | An async image generation job completes.                             |
| `connect.account_updated` | Stripe Connect status changes (onboarding, requirements).            |

A complete reference with payload schemas is on the
[API reference](/api-reference#tag/Webhooks).

## Delivery shape

```http theme={null}
POST /api/neural-draft HTTP/2
Host: example.com
Content-Type: application/json
User-Agent: Neural-Draft-Webhooks/1.0
X-Neural-Draft-Event: order.paid
X-Neural-Draft-Delivery: whd_2Nh4PqRsTuVw
X-Neural-Draft-Signature: t=1745007660,v1=4f3c1e8b6a... (hex)

{
  "id": "evt_2Nh4PqRsTuVw",
  "type": "order.paid",
  "created": 1745007660,
  "data": { /* same shape as the GET resource */ }
}
```

## Verifying the signature

The signature is a hex-encoded HMAC-SHA256 of the **raw body** (do not parse
and re-stringify) using your `signing_secret`. The header is
`t=<unix>,v1=<hex>`; future signature versions will append additional
algorithms (e.g. `v2=`).

<CodeGroup>
  ```ts Node theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verifyNeuralDraftSignature(
    body: string,
    header: string,
    secret: string,
    toleranceSeconds = 300
  ): boolean {
    const parts = Object.fromEntries(
      header.split(",").map((p) => p.split("=") as [string, string])
    );
    const t = Number(parts.t);
    const v1 = parts.v1;
    if (!Number.isFinite(t) || !v1) return false;

    // Reject ancient deliveries — protects against replay.
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - t) > toleranceSeconds) return false;

    const signed = `${t}.${body}`;
    const expected = createHmac("sha256", secret).update(signed).digest("hex");

    // Constant-time compare; both sides must be the same length.
    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(v1, "hex");
    if (a.length !== b.length) return false;
    return timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, time

  def verify_neural_draft_signature(
      body: bytes, header: str, secret: str, tolerance_seconds: int = 300
  ) -> bool:
      parts = dict(p.split("=", 1) for p in header.split(","))
      try:
          t = int(parts["t"])
          v1 = parts["v1"]
      except (KeyError, ValueError):
          return False

      if abs(int(time.time()) - t) > tolerance_seconds:
          return False

      signed = f"{t}.{body.decode('utf-8')}".encode()
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, v1)
  ```

  ```php PHP theme={null}
  <?php
  function verifyNeuralDraftSignature(
      string $body,
      string $header,
      string $secret,
      int $toleranceSeconds = 300
  ): bool {
      $parts = [];
      foreach (explode(',', $header) as $kv) {
          [$k, $v] = array_pad(explode('=', $kv, 2), 2, null);
          $parts[$k] = $v;
      }

      $t = isset($parts['t']) ? (int) $parts['t'] : 0;
      $v1 = $parts['v1'] ?? '';
      if (!$t || !$v1) {
          return false;
      }

      if (abs(time() - $t) > $toleranceSeconds) {
          return false;
      }

      $signed = $t . '.' . $body;
      $expected = hash_hmac('sha256', $signed, $secret);

      return hash_equals($expected, $v1);
  }
  ```
</CodeGroup>

<Warning>
  Always verify against the **raw** request body — if you parse JSON first
  and re-serialize, key ordering changes will break the HMAC. In Express, that
  means `express.raw()` on the route, not `express.json()`. In Next.js App
  Router, use `await req.text()` (not `await req.json()`).
</Warning>

## Retry behavior

If your endpoint doesn't respond `2xx` within 30 seconds, we retry with
exponential backoff: 30 s, 2 m, 10 m, 1 h, 6 h. After 5 failed attempts the
delivery is marked `failed` and visible in the dashboard.

| Attempt | Delay since previous |
| ------- | -------------------- |
| 1       | (immediate)          |
| 2       | 30 seconds           |
| 3       | 2 minutes            |
| 4       | 10 minutes           |
| 5       | 1 hour               |
| 6       | 6 hours              |

You can manually replay any past delivery from the dashboard or via the API:

```bash theme={null}
curl -X POST https://api.neuraldraft.io/v1/webhook-endpoints/whe_.../deliveries/whd_.../redeliver \
  -H "Authorization: Bearer $NEURALDRAFT_API_KEY"
```

The `data` payload is **always** the resource as it stood at delivery time
(not the resource as it stands now). A redeliver replays the original payload.

## Best practices

<AccordionGroup>
  <Accordion title="Acknowledge fast, work async">
    Return `2xx` as soon as you've persisted the raw payload (or queued a job).
    Don't run business logic in the request handler — anything over a few
    seconds risks a retry that double-processes.
  </Accordion>

  <Accordion title="Idempotent processing">
    Treat `X-Neural-Draft-Delivery` as the dedupe key. Index it; reject (and
    `2xx`) duplicates. Retries happen — they're a feature, not a bug.
  </Accordion>

  <Accordion title="Replay protection">
    Reject deliveries with timestamps more than 5 minutes off. The verifier
    helpers above do this for you.
  </Accordion>

  <Accordion title="Rotate secrets">
    Rotate the signing secret quarterly, or instantly if you suspect a leak.
    The dashboard supports a transition window — both old and new secrets
    accepted for up to 24 hours.
  </Accordion>

  <Accordion title="Monitor failures">
    Watch the `last_delivery_status` field on the endpoint resource (also
    visible in the dashboard's delivery log). A `failed` state for more than
    an hour is your cue to investigate.
  </Accordion>

  <Accordion title="Use one endpoint per concern">
    A single endpoint subscribed to many topics is fine; the `X-Neural-Draft-Event`
    header tells you which one. But splitting concerns by URL (orders, content,
    bookings) makes failure isolation cleaner.
  </Accordion>
</AccordionGroup>

## Local development

Use [ngrok](https://ngrok.com), [Cloudflare Tunnel](https://github.com/cloudflare/cloudflared)
or [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) to forward your
localhost to a public URL. Subscribe a test-mode endpoint with that URL, and
trigger events from the dashboard's "send test event" button. The Inspector in
the dashboard shows every delivery, signature, response code, and replay
button.

## What "billed" means

Each delivery costs **0.02 credits**. Retries count — that's why fast,
reliable acknowledgement saves you money. We cap retries at 5 to keep this
predictable.
