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

# Webhooks

> Verify Svix-signed Standard Webhooks from WiseYield with header fallback for Resend, Dodo, and Clerk parity.

When WiseYield delivers a webhook to your application, it is signed using the [Standard Webhooks](https://www.standardwebhooks.com/) specification (the same scheme used by Svix, Resend, Dodo Payments, and Clerk). This guide shows you how to verify the signature and what to expect in the payload.

<Note>
  Outbound webhooks are part of the versioned API expansion roadmap. The signature contract documented here is what integrators should align against today so the wire format does not change at GA.
</Note>

## The contract

WiseYield sends three headers with every webhook delivery:

| Header              | Format                                                 | Purpose                                                             |
| ------------------- | ------------------------------------------------------ | ------------------------------------------------------------------- |
| `webhook-id`        | UUID                                                   | Stable identifier for this delivery (for idempotency)               |
| `webhook-timestamp` | Unix seconds                                           | When the webhook was signed (reject if too old)                     |
| `webhook-signature` | `v1,<base64-signature>` (space-separated for multiple) | HMAC-SHA256 of `{id}.{timestamp}.{body}` using your endpoint secret |

The body is JSON:

```json theme={null}
{
  "type": "farm.created",
  "id": "evt_...",
  "timestamp": "2026-05-17T14:23:01.000Z",
  "data": { /* event-specific */ }
}
```

### Header fallback (Svix parity)

WiseYield reads `webhook-*` first and falls back to `svix-*` on the way in, and emits both header sets on the way out for downstream compatibility. Your handler should do the same:

<CodeGroup>
  ```javascript Node.js theme={null}
  const id =
    req.headers['webhook-id'] ?? req.headers['svix-id'];
  const timestamp =
    req.headers['webhook-timestamp'] ?? req.headers['svix-timestamp'];
  const signature =
    req.headers['webhook-signature'] ?? req.headers['svix-signature'];
  ```

  ```python Python theme={null}
  id = req.headers.get('webhook-id') or req.headers.get('svix-id')
  timestamp = req.headers.get('webhook-timestamp') or req.headers.get('svix-timestamp')
  signature = req.headers.get('webhook-signature') or req.headers.get('svix-signature')
  ```
</CodeGroup>

## Verifying the signature

The canonical approach is to use the official `standardwebhooks` (or `svix`) library — it handles timing-safe comparison, multi-signature parsing, and replay-window validation correctly.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { Webhook } from 'standardwebhooks';

  const wh = new Webhook(process.env.WISEYIELD_WEBHOOK_SECRET);

  app.post('/webhooks/wiseyield', express.raw({ type: 'application/json' }), (req, res) => {
    const headers = {
      'webhook-id': req.headers['webhook-id'] ?? req.headers['svix-id'],
      'webhook-timestamp': req.headers['webhook-timestamp'] ?? req.headers['svix-timestamp'],
      'webhook-signature': req.headers['webhook-signature'] ?? req.headers['svix-signature'],
    };

    let event;
    try {
      event = wh.verify(req.body, headers);
    } catch (err) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // event is the parsed, verified JSON payload
    processEvent(event);
    res.status(200).end();
  });
  ```

  ```python Python theme={null}
  from standardwebhooks import Webhook
  from flask import Flask, request, jsonify
  import os

  app = Flask(__name__)
  wh = Webhook(os.environ['WISEYIELD_WEBHOOK_SECRET'])

  @app.post('/webhooks/wiseyield')
  def handle_webhook():
      headers = {
          'webhook-id': request.headers.get('webhook-id') or request.headers.get('svix-id'),
          'webhook-timestamp': request.headers.get('webhook-timestamp') or request.headers.get('svix-timestamp'),
          'webhook-signature': request.headers.get('webhook-signature') or request.headers.get('svix-signature'),
      }
      try:
          event = wh.verify(request.data, headers)
      except Exception:
          return jsonify({'error': 'Invalid signature'}), 401

      process_event(event)
      return '', 200
  ```
</CodeGroup>

### Why raw body matters

The signature is computed over the **raw request body bytes**, not the JSON-parsed object. Most web frameworks parse JSON by default — you must read the raw body before parsing, or signature verification will fail intermittently when whitespace or key ordering differs.

* **Express**: use `express.raw({ type: 'application/json' })` on the webhook route.
* **Next.js App Router**: use `request.text()` before `JSON.parse()`.
* **Flask**: use `request.data` (not `request.json`).
* **FastAPI**: use `await request.body()`.

## Idempotency

Webhooks can be delivered more than once — retries on network blips, processing failures on your end, etc. Use `webhook-id` as the dedupe key:

```javascript theme={null}
async function processEvent(event, webhookId) {
  const seen = await db.webhookDeliveries.upsert({
    where: { id: webhookId },
    update: {},
    create: { id: webhookId, receivedAt: new Date() },
  });
  if (seen.processedAt) {
    return; // Already handled
  }
  await doTheActualWork(event);
  await db.webhookDeliveries.update({
    where: { id: webhookId },
    data: { processedAt: new Date() },
  });
}
```

A unique constraint on `webhook_id` plus `ON CONFLICT DO NOTHING` is the simplest form. WiseYield uses exactly this pattern internally for the webhooks it consumes.

## Replay window

`webhook-timestamp` is signed alongside the body. Reject deliveries older than \~5 minutes to prevent replay attacks:

```javascript theme={null}
const ageSeconds = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (Math.abs(ageSeconds) > 300) {
  return res.status(401).json({ error: 'Replay window exceeded' });
}
```

The official `standardwebhooks` and `svix` libraries enforce this automatically.

## Retry behavior

When your endpoint returns a non-`2xx` response (or fails to respond within 30 seconds), WiseYield retries with exponential backoff:

| Attempt | Delay      |
| ------- | ---------- |
| 1       | Immediate  |
| 2       | 5 seconds  |
| 3       | 30 seconds |
| 4       | 5 minutes  |
| 5       | 30 minutes |
| 6       | 2 hours    |
| 7       | 5 hours    |
| 8       | 10 hours   |
| 9       | 24 hours   |

After 9 failed attempts, the delivery is marked permanently failed and surfaced in your webhook dashboard. Return `200` as soon as you've verified the signature and queued the event for processing; do any heavy work asynchronously.

## What to log on verification failure

When verification fails, log loudly enough to diagnose:

```javascript theme={null}
logger.warn('WiseYield webhook signature verification failed', {
  webhookId: req.headers['webhook-id'] ?? req.headers['svix-id'],
  seenHeaders: {
    'webhook-id': req.headers['webhook-id'],
    'svix-id': req.headers['svix-id'],
    'webhook-timestamp': req.headers['webhook-timestamp'],
    'svix-timestamp': req.headers['svix-timestamp'],
  },
  bodyLength: req.body.length,
});
```

The most common causes of legitimate-signature-but-failing-verification:

* Body was JSON-parsed before signature check (always read raw bytes first).
* Wrong endpoint secret (rotate, store in env, never commit).
* Reverse-proxy stripping or re-casing headers (check for `Webhook-Id` vs `webhook-id`).
* Reading the wrong header set (you sent `svix-*`, your code only looks for `webhook-*`, or vice versa).

## See also

* [Standard Webhooks specification](https://www.standardwebhooks.com/) — the canonical reference
* [Errors & status codes](/concepts/errors) — return shapes from your endpoint
