> For the complete documentation index, see [llms.txt](https://docs.wandreferrals.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.wandreferrals.com/integrations-and-api-production-rollout-gate-pending/webhooks.md).

# Outbound webhooks

> **API rollout status:** Webhook delivery behavior is documented here, but public REST management of subscriptions has not been approved for production use. A universal server-side API kill switch is not yet implemented, so do not infer that the route is technically unreachable. Use only authorized controlled validation until the production safety gate, review, and rollout approval are complete.

Wand Referrals can fire an outbound HTTP `POST` to a URL you control whenever something happens in your program. These are Zapier-, Make- (Integromat), and custom-endpoint-compatible.

Source of truth: `app/models.outbound-webhooks.server.js` (delivery + signing) and `app/routes/api.v1.webhooks.tsx` (subscription management).

***

## When to use

* Sync currently emitted affiliate-status and commission-created events into your CRM, data warehouse, or Slack.
* Trigger a Zapier/Make automation on `affiliate.approved` or `commission.created`.
* Receive server-side notifications without polling the REST API.

Delivery is **non-blocking and best-effort**: a failed webhook is logged but never blocks or fails the core action that produced it (e.g. an order still commissions even if your endpoint is down). There is currently no automatic retry — build your own reconciliation if you need at-least-once delivery.

***

## Subscribing

Configure webhooks either in **Settings → Integrations** in the merchant admin (the "Outbound webhooks" card), or via the REST API (`POST /api/v1/webhooks` — see [`reference.md`](/integrations-and-api-production-rollout-gate-pending/reference.md)).

When subscribing you provide:

* **`url`** (required) — must use public HTTPS. REST API subscriptions are checked by the established SSRF guard (`app/ssrf-guard.server.js`) before persistence and again before delivery. The current merchant Settings form checks URL shape only; a saved HTTP, internal, private-network, or metadata target remains listed but the delivery guard refuses it. Use only public HTTPS endpoints.
* **`events`** (optional) — an array of event types to receive. An empty array means **all** events.
* **`secret`** (optional but strongly recommended) — your signing secret, stored encrypted at rest and used to sign every delivery so you can verify authenticity.

Fire a test dispatch with `POST /api/v1/webhooks?test=1` and a JSON body containing a valid public-HTTPS `url` (for example, `{ "url": "https://example.com/webhook" }`). The route currently validates this required body URL even though it does not create a subscription or dispatch directly to that URL. The generic `test` event is sent to existing subscriptions with an empty event filter or a filter that explicitly contains `test`. Although `test` is absent from `available_events`, subscription creation currently accepts arbitrary filter strings, including `test`; subscriptions filtered only to other values do not receive the test dispatch. The API returns `200` even when no subscription is eligible, so confirm receipt at an authorized temporary endpoint with an empty filter or an explicit `test` filter.

API subscription and removal re-read and conditionally claim the tenant's exact encrypted settings blob, so a concurrent settings writer returns `409` instead of being overwritten. The configuration change and a URL/secret-free audit commit together; persistence or audit unavailability returns `503` without committing a partial subscription change. Delivery diagnostics use fixed codes and exclude destination URLs, signing secrets, payloads, and raw network/provider errors.

***

## Event types

From `WEBHOOK_EVENTS` in `app/models.outbound-webhooks.server.js`:

| Currently emitted event    | Fires when                            |
| -------------------------- | ------------------------------------- |
| `affiliate.approved`       | An affiliate is approved              |
| `affiliate.rejected`       | An affiliate is rejected              |
| `affiliate.status_changed` | An affiliate's status changes         |
| `commission.created`       | A commission is recorded for an order |

`WEBHOOK_EVENTS` and the `available_events` field returned by `GET /api/v1/webhooks` also expose these reserved filter names: `affiliate.created`, `commission.approved`, `commission.paid`, `commission.reversed`, `payout.completed`, `payout.failed`, `registration.submitted`, `chat.message_received`, `ticket.created`, and `milestone.reached`. No current application call site emits them. Subscriptions can save those filters but receive nothing for them until a producer is implemented and released. The generic `test` event is emitted only by the test action. It is not advertised by `available_events`, but the current subscription API does not validate filter names and therefore accepts an explicit `test` filter, which does receive test dispatches.

***

## Delivery format

Each delivery is an HTTP `POST` with a JSON body and these headers:

| Header             | Value                                                                      |
| ------------------ | -------------------------------------------------------------------------- |
| `Content-Type`     | `application/json`                                                         |
| `X-Wand-Event`     | the event type (e.g. `commission.created`)                                 |
| `X-Wand-Signature` | HMAC-SHA256 hex digest of the raw request body (empty string if no secret) |

Body shape:

```json
{
  "event": "commission.created",
  "timestamp": "2026-03-15T18:30:00.000Z",
  "shop_id": "clshop123",
  "data": { "...": "event-specific payload" }
}
```

`timestamp` is ISO-8601 UTC. `shop_id` is your shop's internal id. `data` is the event-specific payload.

***

## Verifying the HMAC signature

The signature is computed as:

```
X-Wand-Signature = HMAC_SHA256( key = <your webhook secret>, message = <raw JSON request body> ).hexdigest()
```

Reference: `computeSignature()` in `app/models.outbound-webhooks.server.js` — `createHmac("sha256", secret).update(body).digest("hex")`, where `body` is the exact serialized JSON that was sent. **Verify against the raw request body bytes** — do not re-serialize the parsed JSON, or key ordering/whitespace may change the digest.

Use a constant-time comparison to avoid timing attacks. If you did not set a secret, the `X-Wand-Signature` header is an empty string and cannot be verified — set a secret to enable verification.

### Node.js (Express)

```js
import crypto from "node:crypto";

// Capture the RAW body so the digest matches exactly.
app.post("/wand-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body; // Buffer
  const signature = req.get("X-Wand-Signature") || "";
  const expected = crypto
    .createHmac("sha256", process.env.WAND_WEBHOOK_SECRET)
    .update(raw)
    .digest("hex");

  const ok =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!ok) return res.status(401).send("invalid signature");

  const payload = JSON.parse(raw.toString("utf8"));
  console.log(req.get("X-Wand-Event"), payload.data);
  res.sendStatus(200);
});
```

### Python (Flask)

```python
import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/wand-webhook")
def wand_webhook():
    raw = request.get_data()  # raw bytes
    signature = request.headers.get("X-Wand-Signature", "")
    expected = hmac.new(
        os.environ["WAND_WEBHOOK_SECRET"].encode(),
        raw,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        abort(401)

    event = request.headers.get("X-Wand-Event")
    data = request.get_json()["data"]
    print(event, data)
    return "", 200
```

***

## Best practices

* **Respond fast** (2xx quickly), then process asynchronously — there is no retry, and slow endpoints risk timeouts.
* **Always set a secret** and verify `X-Wand-Signature` before trusting a payload.
* **Be idempotent** on your side: an event may in principle arrive more than once; key your processing on the payload's identifiers.
* **Filter by `events`** at subscribe time rather than receiving everything and discarding — it reduces noise and load.

***

## Related

* [`reference.md`](/integrations-and-api-production-rollout-gate-pending/reference.md) — full REST API reference, including the `/api/v1/webhooks` endpoints.
* Merchant help: [Integrations overview](/integrations-and-api-production-rollout-gate-pending/overview.md) and the user manual's outbound-webhooks article.
