# ViperFlow Webhooks

ViperFlow pushes an HTTPS request to your server whenever something changes in a ViperFlow
account: an order is created, a payment clears, a document is issued, stock moves. Events are
emitted by the **database**, so a change made in the web app, in the desktop app, through the
[REST API](API.md) or by an automation all produce the same event.

This document covers **outbound** webhooks (ViperFlow → you) and **inbound** connections
(your system → ViperFlow).

- Endpoints, secrets, delivery history and test sends live in the app under
  **הגדרות › מפתחים › Webhooks** (Settings › Developers › Webhooks).
- REST API reference: [`API.md`](API.md) · machine-readable spec: [`openapi.yaml`](openapi.yaml)

## Contents

1. [How it works](#1-how-it-works)
2. [Creating an endpoint](#2-creating-an-endpoint)
3. [The event envelope](#3-the-event-envelope)
4. [Request headers](#4-request-headers)
5. [Verifying signatures](#verifying-signatures)
6. [Secret rotation](#6-secret-rotation)
7. [How your endpoint should respond](#7-how-your-endpoint-should-respond)
8. [Retries](#8-retries)
9. [Auto-disable and manual retry](#9-auto-disable-and-manual-retry)
10. [Ordering, duplicates and coalescing](#10-ordering-duplicates-and-coalescing)
11. [Loop protection](#11-loop-protection)
12. [URL rules and network safety](#12-url-rules-and-network-safety)
13. [Event catalog](#13-event-catalog)
14. [Payload visibility and permissions](#14-payload-visibility-and-permissions)
15. [Inbound connections](#15-inbound-connections)
16. [Known limitations](#16-known-limitations)

---

## 1. How it works

```
 change (web app / API / desktop / cron)
        │
        ├─ database trigger ──► integration_events        (one row per change, with the entity snapshot)
        │                            │
        │                            └─ fan-out ──► webhook_deliveries   (one row per subscribed endpoint)
        │                                                    │
 every 15 s  ─────────────────────────────────────────────────┘
        │
        └─ delivery worker ──► POST https://your-server/hook
                                   signed with your endpoint secret,
                                   retried with exponential backoff
```

- An event is created **only if** at least one enabled endpoint of the account subscribes to its
  type. No endpoints means no event rows and no storage cost.
- The payload snapshot (`data`) is taken at the **end of the transaction that made the change**,
  so it is the committed state, not an intermediate one.
- Events and their deliveries are kept for **30 days**, then removed.
- Delivery is **at least once**: design your handler to be idempotent (see
  [§10](#10-ordering-duplicates-and-coalescing)).

## 2. Creating an endpoint

In the app: **הגדרות › מפתחים › Webhooks › הוספת יעד**. You need the `integrations.manage`
permission. You provide:

| Field | Notes |
|---|---|
| URL | `https://` only, at most 2048 characters ([§12](#12-url-rules-and-network-safety)) |
| Description | optional, ≤ 200 characters |
| Event types | 1–64 types from the [catalog](#13-event-catalog), or `*` for all events |
| Excluded API keys | events caused by these API keys are not delivered to this endpoint ([§11](#11-loop-protection)) |
| Enabled | can be switched off without deleting the endpoint |

On creation ViperFlow shows the signing secret **once** (`whsec_` + 40 characters). It is stored
encrypted (Supabase Vault) and can be revealed again in the app by a user with
`integrations.manage`; it is never sent in an event.

Limits and rules:

- Up to **20 endpoints** per account.
- Subscribing to `*` requires the account **owner** role. Other users must enumerate the types
  they want, and may only subscribe to event types whose read permissions they hold themselves
  (otherwise `403 event_exceeds_caller`) — so a salesperson cannot point an endpoint at their own
  server to receive procurement costs.
- An unknown event type is refused (`400`). Adding a new event type to ViperFlow never changes
  what an existing endpoint receives, unless it subscribes to `*`.
- **Send test** (`אירוע בדיקה`) delivers a `webhook.test` event immediately, even to a disabled
  endpoint, and shows the response status and body snippet inline. Use it to verify your
  signature check before subscribing to real events.

## 3. The event envelope

The body is a single JSON object. The **exact bytes** of that body are what the signature covers,
so verify before parsing, and sign/compare the raw body rather than a re-serialised version.

```json
{
  "id": "evt_9b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e",
  "type": "order.status_changed",
  "created_at": "2026-09-15T08:12:03.417Z",
  "api_version": "v1",
  "livemode": true,
  "origin": { "source": "api", "api_key_id": "0d6c8f9e-2b1a-4c3d-9e8f-7a6b5c4d3e2f", "request_id": "req_4f1c2a9b7e3d5c6a8b9e0f12", "integration": "woocommerce" },
  "data": {
    "id": "a3e1b2c4-5d6f-4a7b-8c9d-0e1f2a3b4c5d",
    "object": "order",
    "order_number": "ORD-2026-0412",
    "status": "confirmed",
    "…": "the full object, exactly as GET /v1/orders/{id} returns it"
  },
  "previous": { "status": "draft" }
}
```

| Field | Notes |
|---|---|
| `id` | `evt_` + 32 hex characters. Stable across retries — use it to de-duplicate |
| `type` | Event type from the [catalog](#13-event-catalog) |
| `created_at` | ISO-8601 UTC with milliseconds; when the event was recorded |
| `api_version` | `v1`. The payload shape follows the same versioning policy as the REST API |
| `livemode` | Always `true`. Test-mode API keys never produce events ([`API.md` §4](API.md#4-environments-and-test-mode)) |
| `origin` | Who caused the change ([§11](#11-loop-protection)) |
| `data` | The full entity object, identical to the REST `GET .../{id}` response (expanded: orders include `items`, products include `components` and `variant_prices`, documents include `lines` and `payments`). For `*.deleted` events a minimal snapshot with `"deleted": true` |
| `previous` | `null`, or the changed fields as they were **before** the change (see the catalog table) |

The top-level keys always appear in the order above, and keys of nested objects are sorted, so a
re-delivery of the same event is byte-identical.

**`*.deleted` payloads** carry only what the deleted row could still tell you:

| Event | `data` keys (plus `"deleted": true`) |
|---|---|
| `customer.deleted` | `id`, `object`, `customer_number`, `display_number`, `business_name` |
| `order.deleted` | `id`, `object`, `order_number`, `status`, `customer_id`, `customer_name` |
| `payment.deleted` | `id`, `object`, `order_id`, `customer_id`, `direction`, `amount` |
| `product.deleted` | `id`, `object`, `name`, `category_id` |
| `supplier.deleted` | `id`, `object`, `name`, `business_id` |
| `purchase_order.deleted` | `id`, `object`, `po_number`, `status`, `supplier_id` |
| `warehouse.task.deleted` | `id`, `object`, `type`, `order_id`, `title` |

`webhook.test` carries no entity — its payload is
`{"message": "ViperFlow webhook test", "endpoint_id": "<uuid>"}`, `previous` is `null` and
`origin.source` is `system`. Treat it as a connectivity check only.

## 4. Request headers

```
POST /your/hook HTTP/1.1
Content-Type: application/json
User-Agent: ViperFlow-Webhooks/1.0
X-ViperFlow-Event: order.status_changed
X-ViperFlow-Event-Id: evt_9b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e
X-ViperFlow-Delivery: whd_1a2b3c4d5e6f708192a3b4c5d6e7f809
X-ViperFlow-Attempt: 1
X-ViperFlow-Timestamp: 1789459923
X-ViperFlow-Signature: v1=6f1a…c93b
X-ViperFlow-Origin: api
```

| Header | Meaning |
|---|---|
| `X-ViperFlow-Event` | Event type (same as `type` in the body) |
| `X-ViperFlow-Event-Id` | `evt_…` — the de-duplication key |
| `X-ViperFlow-Delivery` | `whd_…` — this endpoint's delivery; the same value for every attempt |
| `X-ViperFlow-Attempt` | 1-based attempt number |
| `X-ViperFlow-Timestamp` | Unix seconds; part of the signed payload |
| `X-ViperFlow-Signature` | `v1=<hex>`, possibly several comma-separated `v1=` entries ([§6](#6-secret-rotation)) |
| `X-ViperFlow-Origin` | `api`, `app`, `inbound` or `system` — the `origin.source` of the event |

ViperFlow sends no cookies and no `Authorization` header. If your receiver sits behind an
authenticating proxy, use a secret path segment or a query parameter in the endpoint URL; the URL
is stored as given and requested verbatim.

## Verifying signatures

**Always verify.** Anyone can POST JSON at your URL.

The algorithm:

```
signed_payload = "<X-ViperFlow-Timestamp>" + "." + <raw request body>
expected       = hex( HMAC_SHA256( key = <whole secret string, "whsec_" prefix included>,
                                   message = signed_payload ) )
```

Accept the request when **all** of these hold:

1. `X-ViperFlow-Timestamp` is an integer and `|now − timestamp| ≤ 300` seconds (5 minutes).
2. **Any** `v1=` entry of `X-ViperFlow-Signature` equals `expected`.
3. The comparison is constant-time.

Note the details that bite people: the secret is used as the UTF-8 bytes of the **entire** string
including `whsec_`; the signature is lowercase hex (not base64); and the body must be the raw
bytes — `JSON.stringify(JSON.parse(body))` will not match.

### Node / TypeScript

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

/** `rawBody` must be the exact bytes received (Buffer or string), never a re-serialised object. */
export function verifyViperFlowSignature(
  rawBody: string | Buffer,
  headers: { "x-viperflow-timestamp"?: string; "x-viperflow-signature"?: string },
  secret: string,
): boolean {
  const timestamp = headers["x-viperflow-timestamp"];
  const header = headers["x-viperflow-signature"];
  if (!timestamp || !header || !/^\d{1,12}$/.test(timestamp)) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > TOLERANCE_SECONDS) return false;

  const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : rawBody;
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(body)
    .digest();                                   // 32 bytes

  // During a secret rotation the header carries two entries: v1=<new>,v1=<old>.
  let matched = false;
  for (const part of header.split(",")) {
    const [scheme, value] = part.trim().split("=", 2);
    if (scheme !== "v1" || !value || !/^[0-9a-f]{64}$/i.test(value)) continue;
    const provided = Buffer.from(value.toLowerCase(), "hex");
    // timingSafeEqual throws on a length mismatch; the regex above guarantees 32 bytes.
    if (timingSafeEqual(expected, provided)) matched = true;   // no early exit
  }
  return matched;
}
```

Express, keeping the raw body:

```ts
import express from "express";

const app = express();

app.post(
  "/viperflow/hook",
  express.raw({ type: "application/json", limit: "2mb" }),
  (req, res) => {
    if (!verifyViperFlowSignature(req.body, req.headers as never, process.env.VF_WEBHOOK_SECRET!)) {
      return res.status(401).end();                  // ViperFlow will not retry a 401
    }
    const event = JSON.parse(req.body.toString("utf8"));

    // 1. De-duplicate, 2. enqueue, 3. answer fast (see §7).
    void enqueue(event);                             // your own queue / table
    res.status(204).end();
  },
);
```

### Python

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def verify_viperflow_signature(raw_body: bytes, timestamp: str, signature_header: str, secret: str) -> bool:
    """raw_body must be request.get_data() / await request.body() — the exact bytes."""
    if not timestamp or not timestamp.isdigit() or not signature_header:
        return False
    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        timestamp.encode("ascii") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()

    matched = False
    for part in signature_header.split(","):
        scheme, _, value = part.strip().partition("=")
        if scheme != "v1" or len(value) != 64:
            continue
        # compare_digest is constant time; do not short-circuit the loop.
        matched |= hmac.compare_digest(expected, value.lower())
    return matched
```

Flask:

```python
@app.post("/viperflow/hook")
def viperflow_hook():
    ok = verify_viperflow_signature(
        request.get_data(),
        request.headers.get("X-ViperFlow-Timestamp", ""),
        request.headers.get("X-ViperFlow-Signature", ""),
        os.environ["VF_WEBHOOK_SECRET"],
    )
    if not ok:
        return "", 401
    enqueue(request.get_json())
    return "", 204
```

### PHP

```php
<?php
const VF_TOLERANCE_SECONDS = 300;

function vf_verify_signature(string $rawBody, string $timestamp, string $header, string $secret): bool
{
    if ($timestamp === '' || !ctype_digit($timestamp) || $header === '') {
        return false;
    }
    if (abs(time() - (int) $timestamp) > VF_TOLERANCE_SECONDS) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    $matched = false;
    foreach (explode(',', $header) as $part) {
        $pieces = explode('=', trim($part), 2);
        if (count($pieces) !== 2 || $pieces[0] !== 'v1' || strlen($pieces[1]) !== 64) {
            continue;
        }
        // hash_equals is constant time; keep checking every entry.
        $matched = hash_equals($expected, strtolower($pieces[1])) || $matched;
    }
    return $matched;
}

$rawBody   = file_get_contents('php://input');           // never $_POST
$timestamp = $_SERVER['HTTP_X_VIPERFLOW_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_VIPERFLOW_SIGNATURE'] ?? '';

if (!vf_verify_signature($rawBody, $timestamp, $signature, getenv('VF_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
vf_enqueue($event);                                       // your own queue
http_response_code(204);
```

**Replay protection.** The 5-minute window stops a captured request from being replayed later,
but a replay *inside* the window is valid by construction — that is exactly what a ViperFlow
retry looks like. De-duplicate by `id` ([§10](#10-ordering-duplicates-and-coalescing)).

## 6. Secret rotation

`סיבוב סוד` (rotate) issues a new secret and keeps the old one valid for **24 hours**. During
that window every delivery is signed with both, and the header carries two entries:

```
X-ViperFlow-Signature: v1=<new signature>,v1=<old signature>
```

A receiver that accepts *any* matching `v1=` entry — as all the examples above do — keeps working
while you deploy the new secret. After 24 hours the old secret is deleted and only the new one is
sent. Reveal the current secret in the app at any time (`integrations.manage`).

If the secret cannot be read when a delivery is attempted, the attempt fails permanently with
`signing_secret_unavailable`; ViperFlow never sends an unsigned event.

## 7. How your endpoint should respond

| Rule | Detail |
|---|---|
| Status | Any `2xx` is success. `204` is ideal |
| Time budget | **10 seconds**, then the attempt is a `timeout` and is retried |
| Redirects | Not followed. A `3xx` is a permanent failure (`redirect_not_followed`) |
| Body | Optional. ViperFlow keeps up to 512 characters of it for the delivery log, and only when the `Content-Type` is `application/json` or `text/*` |
| `Retry-After` | Honoured on `429` and `503`: it can push the next attempt **later** than the schedule, never earlier (capped at 24 hours) |

Do the minimum synchronously: verify the signature, de-duplicate, write the event to a queue or a
table, return. Anything slower than 10 seconds turns a working integration into a retry storm.

## 8. Retries

A delivery gets at most **7 attempts** (the first plus 6 retries):

| After failed attempt | Next attempt after | With ±10 % jitter |
|---|---|---|
| 1 | 1 minute | 54 s – 66 s |
| 2 | 5 minutes | 4.5 – 5.5 min |
| 3 | 30 minutes | 27 – 33 min |
| 4 | 2 hours | 1.8 – 2.2 h |
| 5 | 6 hours | 5.4 – 6.6 h |
| 6 | 24 hours | 21.6 – 26.4 h |

After the 7th failed attempt the delivery is `failed` and is not tried again automatically. Total
span: roughly 33 hours.

What is retried:

| Result | Retried? | Stored error |
|---|---|---|
| `2xx` | — (success) | — |
| `408`, `425`, `429` | yes | `http_408` / `http_425` / `http_429` |
| `5xx` | yes | `http_<status>` |
| Connection refused / reset / TLS failure | yes | `network_error` |
| No response within 10 s | yes | `timeout` |
| DNS NXDOMAIN / SERVFAIL / no records / resolver timeout | yes | `dns_error` |
| Worker lease expired mid-attempt | yes | `worker_timeout` |
| `3xx` | **no** | `redirect_not_followed` |
| Other `4xx` (including `401`, `403`, `404`, `410`) | **no** | `http_<status>` |
| URL no longer passes validation | **no** | `invalid_url` |
| Host resolves to a private/reserved address, or DNS is unavailable | **no** | `destination_blocked` |
| Signing secret unreadable | **no** | `signing_secret_unavailable` |

A `4xx` means "your server rejected this event on purpose", so ViperFlow stops. If you return
`400` because of a bug, fix it and use **retry** ([§9](#9-auto-disable-and-manual-retry)).

## 9. Auto-disable and manual retry

**Auto-disable.** An endpoint is switched off (`enabled = false`,
`disabled_reason = auto_failing`) when a delivery ends `failed` while the endpoint has
**10 consecutive failed deliveries** and has had no success in the last **3 days**. It is also
disabled immediately if a delivery resolves to a private or reserved address after the response
was received (a DNS-rebinding attempt, [§12](#12-url-rules-and-network-safety)). A single success
resets the failure counter to 0.

Nothing queues up while an endpoint is disabled: events emitted then create **no** delivery for
it. Re-enabling starts from the next event; use the REST API with `updated_since` to backfill
what you missed ([`API.md` §11](API.md#11-incremental-sync-recipe)).

**Manual retry.** In **משלוחי Webhook** (Deliveries) a `failed` or `cancelled` delivery has a
retry action: it makes exactly **one** more attempt, inline, and shows the response. Succeeding
marks the delivery `succeeded`; failing leaves it `failed` (retry again if you like). A delivery
that is still `pending` or already `succeeded` cannot be retried (`409`).

The Deliveries screen shows, per delivery: status, attempt count, next attempt time, last
response status, last error, duration, the full envelope and every attempt with its response
snippet.

## 10. Ordering, duplicates and coalescing

- **At least once.** A delivery can arrive more than once — for example when your `2xx` is lost
  on the way back. Keep the `id` (`evt_…`) of every processed event and ignore repeats. A
  `UNIQUE` column on the event id is the simplest correct implementation.
- **No ordering guarantee.** Retries and jitter mean a later event can arrive before an earlier
  one. Never derive state from arrival order. Use `created_at`, or re-fetch the entity through the
  REST API and store that (the API always returns the current state).
- **Coalescing.** For `*.created` and `*.updated` events ViperFlow deliberately collapses rapid
  repeats of the same entity into one event, so a web-app save that writes an order and its lines
  in several statements produces one `order.updated`, not eight:
  - within one database transaction, a second `*.updated` (or `*.created`) for the same entity
    reuses the existing event and merges `previous`;
  - across transactions, a new `*.updated`/`*.created` for the same entity **within 10 seconds**,
    from the same origin, joins the existing event — but only while no attempt for it has started
    yet;
  - an `*.updated` is **dropped** when the same transaction also deleted the entity (you get only
    `*.deleted`);
  - a coalescible event is therefore delivered about **3 seconds** after the change, which is the
    window that makes the merging possible. Status, payment-status, transition, document and
    inventory events are not coalescible and go out immediately.
  The consequence to design for: **one event can represent several changes**, and its `data` is
  the state at the end. Do not count events; reconcile state.
- **`previous` is a hint, not a diff.** It carries the few fields the trigger compared (see the
  catalog), merged when events coalesce. It is `null` on creates and on plain updates.
- **Deletes are the only way to observe deletion** — the REST API's lists simply stop returning
  the record.
- **Order item ids are not stable**: editing an order in the web app rewrites its lines with new
  ids. Replace your copy of an order's `items` wholesale.
- **Customer statistics changes emit no event** even though they move `customers.updated_at`.

## 11. Loop protection

Every event carries who caused it:

```json
"origin": { "source": "api", "api_key_id": "0d6c…3e2f", "request_id": "req_4f1c…0f12", "integration": "woocommerce" }
```

| `source` | When | Extra keys |
|---|---|---|
| `api` | A REST API write | `api_key_id`, `request_id`, `integration` (from the `X-ViperFlow-Integration` request header) |
| `app` | A signed-in user in the web or desktop app | — |
| `inbound` | An inbound connection handler | `connection_id`, `provider`, `external_event_id` |
| `system` | Cron jobs, database maintenance, service-role work | — |

Two independent mechanisms keep a sync from feeding itself:

1. **`X-ViperFlow-Origin` / `origin.source`** — skip events your own integration caused. Checking
   `origin.api_key_id` (or `origin.integration`) is more precise than the source alone.
2. **Excluded API keys** — on the endpoint, list the API keys whose writes must not be delivered
   to it (up to 50). Events caused by those keys create no delivery for that endpoint at all,
   which is stronger than filtering on your side: nothing is sent and nothing is stored.

Option 2 is the recommended setup for a two-way sync: one API key for your integration's writes,
listed as excluded on your own endpoint.

## 12. URL rules and network safety

A destination URL is validated when it is saved **and again before every single attempt**:

| Rule | Rejected value |
|---|---|
| `https://` only | `http://`, any other scheme |
| No credentials in the URL | `https://user:pass@host/…` |
| At most 2048 characters | longer |
| Real public hostname | `localhost`, `*.local`, `*.internal`, `*.intranet`, `*.lan`, `*.home`, `*.corp`, `*.private`, `*.arpa` and similar internal-only names |
| Public IP only if an IP literal is used | private, loopback, link-local, CGNAT, multicast, reserved ranges, IPv4-mapped/NAT64/6to4 forms of them |
| Port 443 or 1024–65535 | any other privileged port |

Before each attempt the worker resolves the host itself (A and AAAA in parallel, 3-second budget)
and refuses to connect when **any** resolved address is private or reserved. It **fails closed**:
if DNS cannot be consulted at all, the attempt is a permanent `destination_blocked` rather than a
connection to an unknown address. After the response arrives the host is resolved again; if it is
private now, the response body is discarded, the attempt is recorded as `destination_blocked` and
the endpoint is disabled.

> **Residual risk (TOCTOU).** `fetch` performs its own DNS lookup, which ViperFlow cannot observe.
> A resolver that answers public → private → public between the pre-check, the connection and the
> post-check could get one request through to a private address before the endpoint is disabled.
> Closing this completely needs connection-level pinning, which the Edge runtime does not expose.
> The mitigations in place are the pre-check, the post-check, the immediate auto-disable, and the
> fact that no response body is stored in that case.

Practical consequences for you: use a stable public hostname with a normal TLS certificate, and
do not point an endpoint at a host whose DNS you round-robin into private space.

## 13. Event catalog

41 event types. `previous` lists what the event carries beyond `null`.

| Type | Hebrew label | Entity | Required scope | When | `previous` |
|---|---|---|---|---|---|
| `customer.created` | לקוח נוצר | `customer` | `customers:read` | A customer row is inserted | — |
| `customer.updated` | לקוח עודכן | `customer` | `customers:read` | Customer details change, or a contact is added/changed/removed | — |
| `customer.deleted` | לקוח נמחק | `customer` | `customers:read` | A customer is deleted | — |
| `order.created` | הזמנה נוצרה | `order` | `orders:read` | An order row is inserted (status `draft`) | — |
| `order.updated` | הזמנה עודכנה | `order` | `orders:read` | Header fields (customer, contact, event, dates, totals, discounts, notes, payment terms, buffers, `event_confirmed`, `spec_changed_after_pick`) or the order's lines change | — |
| `order.status_changed` | סטטוס הזמנה השתנה | `order` | `orders:read` | `status` changes, for any transition | `{status}` |
| `order.payment_status_changed` | סטטוס תשלום הזמנה השתנה | `order` | `orders:read` | `payment_status` changes (`unpaid`/`partial`/`paid`) | `{payment_status}` |
| `order.confirmed` | הזמנה אושרה | `order` | `orders:read` | `status` becomes `confirmed` | `{status}` |
| `order.picked` | ליקוט הזמנה הושלם | `order` | `orders:read` | `status` becomes `picked` | `{status}` |
| `order.delivered` | הזמנה סופקה | `order` | `orders:read` | `status` becomes `delivered` | `{status}` |
| `order.returned` | ציוד הוחזר | `order` | `orders:read` | `status` becomes `returned` | `{status}` |
| `order.cancelled` | הזמנה בוטלה | `order` | `orders:read` | `status` becomes `cancelled` | `{status}` |
| `order.deleted` | הזמנה נמחקה | `order` | `orders:read` | An order is deleted | — |
| `payment.created` | תשלום נרשם | `payment` | `payments:read` | A payment row is inserted | — |
| `payment.updated` | תשלום עודכן | `payment` | `payments:read` | Amount, method, status, date, reference or cheque fields change | `{status}` when the status changed |
| `payment.voided` | תשלום בוטל | `payment` | `payments:read` | `voided_at` goes from null to a value | `{status}` |
| `payment.deleted` | תשלום נמחק | `payment` | `payments:read` | A payment is deleted | — |
| `document.issued` | מסמך הופק | `document` | `documents:read` | `status` goes `draft` → `issued`. May already carry `allocation.status: "pending"` | `{status}` |
| `document.allocation_updated` | מספר הקצאה עודכן | `document` | `documents:read` | After issue, the Tax Authority allocation status or number changes | `{allocation: {status, number}}` |
| `document.accepted` | הצעת מחיר אושרה | `document` | `documents:read` | `accepted_at` goes from null to a value (customer signed a quote) | — |
| `document.cancelled` | מסמך בוטל | `document` | `documents:read` | `status` becomes `cancelled` | `{status}` |
| `product.created` | מוצר נוצר | `product` | `products:read` | A product row is inserted | — |
| `product.updated` | מוצר עודכן | `product` | `products:read` | Product fields, components or variant prices/images change | — |
| `product.deleted` | מוצר נמחק | `product` | `products:read` | A product is deleted | — |
| `inventory.updated` | מלאי עודכן | `inventory_item` | `inventory:read` | `total_quantity` or the in-maintenance quantity of a product changes | `{total_quantity, in_maintenance_quantity}` |
| `warehouse.task.created` | משימת מחסן נוצרה | `warehouse_task` | `warehouse:read` | A warehouse task is created (e.g. picking on order confirm) | — |
| `warehouse.task.updated` | משימת מחסן עודכנה | `warehouse_task` | `warehouse:read` | Status, priority, title, description, schedule, assignee, notes, quantity or type change | `{status}` when the status changed |
| `warehouse.task.completed` | משימת מחסן הושלמה | `warehouse_task` | `warehouse:read` | `status` becomes `completed` | `{status}` |
| `warehouse.task.deleted` | משימת מחסן נמחקה | `warehouse_task` | `warehouse:read` | A task is deleted (e.g. on order cancel) | — |
| `supplier.created` | ספק נוצר | `supplier` | `suppliers:read` | A supplier row is inserted | — |
| `supplier.updated` | ספק עודכן | `supplier` | `suppliers:read` | Supplier details or contacts change | — |
| `supplier.deleted` | ספק נמחק | `supplier` | `suppliers:read` | A supplier is deleted | — |
| `purchase_order.created` | הזמנת רכש נוצרה | `purchase_order` | `purchase_orders:read` | A purchase order is inserted | — |
| `purchase_order.updated` | הזמנת רכש עודכנה | `purchase_order` | `purchase_orders:read` | Header fields or lines change, or the status moves to a value with no dedicated event | `{status}` when the status changed |
| `purchase_order.sent` | הזמנת רכש נשלחה | `purchase_order` | `purchase_orders:read` | `status` becomes `sent` | `{status}` |
| `purchase_order.confirmed` | הזמנת רכש אושרה | `purchase_order` | `purchase_orders:read` | `status` becomes `confirmed` | `{status}` |
| `purchase_order.partially_received` | הזמנת רכש התקבלה חלקית | `purchase_order` | `purchase_orders:read` | `status` becomes `partially_received` | `{status}` |
| `purchase_order.received` | הזמנת רכש התקבלה | `purchase_order` | `purchase_orders:read` | `status` becomes `received` | `{status}` |
| `purchase_order.cancelled` | הזמנת רכש בוטלה | `purchase_order` | `purchase_orders:read` | `status` becomes `cancelled` | `{status}` |
| `purchase_order.deleted` | הזמנת רכש נמחקה | `purchase_order` | `purchase_orders:read` | A purchase order is deleted | — |
| `webhook.test` | אירוע בדיקה | `webhook` | none | Sent manually from the Developers screen | — |

Notes:

- A status change emits **both** `order.status_changed` and the specific
  `order.<new status>` event. Subscribe to one of the two, not both, unless you want two requests.
- A purchase-order status change to a value without a dedicated event emits
  `purchase_order.updated` with `previous.status`.
- `order.updated` also fires when only `payment_notes` changes, although the payload never
  contains that field (it is internal).
- Child-row changes are reported on the parent: order lines → `order.updated`; customer contacts →
  `customer.updated`; product components and variant images → `product.updated`; supplier
  contacts → `supplier.updated`; purchase-order lines → `purchase_order.updated`.
- Documents have no delete event.

## 14. Payload visibility and permissions

Event payloads contain business data, so ViperFlow gates who can see them **inside the app**:

- The delivery list shows metadata (type, status, attempts, response status) to anyone with
  `integrations.view`.
- The envelope — `data` and `previous` — is shown only to the account owner or to a user who holds
  the permissions of the event type's **required scope** (see the table above). Otherwise the
  envelope is returned with `data: null` and the screen says `תוכן האירוע מוסתר`
  ("event content hidden").
- Your receiving server, of course, gets the full payload. Treat the endpoint URL and its secret
  as credentials for that data.
- Permissions: `integrations.view` (metadata and logs) and `integrations.manage` (create, edit,
  delete, secrets, test sends, retries).

## 15. Inbound connections

An inbound connection lets **your** system POST to ViperFlow without an API key: ViperFlow gives
you a URL and a shared secret, and verifies the signature of every request.

Create one in **הגדרות › מפתחים › חיבורים נכנסים** (`integrations.manage`). Up to 20 per account.

The URL shown to you:

```
https://viperflow-api.srv2122641.hstgr.cloud/functions/v1/integrations/<provider>/webhook/<connection id>?forceFunctionRegion=ap-northeast-1
```

Keep the `forceFunctionRegion` query parameter — it pins the request to the region the database
runs in.

### Providers

| Provider | Signature header | Signature value | Timestamp check | Secret |
|---|---|---|---|---|
| `generic` | `X-ViperFlow-Signature` (+ `X-ViperFlow-Timestamp`) | `v1=` + hex HMAC-SHA256 of `"<timestamp>.<raw body>"` — the same scheme ViperFlow uses outbound | ±300 s, **required** | ViperFlow-generated (`whsec_…`) or one you paste |
| `woocommerce` | `X-WC-Webhook-Signature` | base64 HMAC-SHA256 of the raw body | none available | ViperFlow-generated or one you paste |
| `shopify` | `X-Shopify-Hmac-Sha256` | base64 HMAC-SHA256 of the raw body | `X-Shopify-Triggered-At`, ±300 s, only when the header is present | **you must supply it** — Shopify chooses the key |

Rotate (`סיבוב סוד`) is available for `generic` and `woocommerce` and returns a fresh generated
secret; for `shopify` use "set secret" with the value from Shopify. A secret you supply is
16–256 characters. Setting a secret deletes the previous one immediately — there is no grace
period on inbound connections, so update both sides together.

### Idempotency and replay

| Provider | De-duplication key | Notes |
|---|---|---|
| `generic` | the `id` field inside the signed JSON body | **required** — a validly signed body without `id` is rejected with `400` and not stored |
| `woocommerce` | SHA-256 of the raw body | `X-WC-Webhook-Delivery-ID` is stored for display only |
| `shopify` | SHA-256 of the raw body | `X-Shopify-Webhook-Id` is stored for display only |

A repeat of a key already seen for that connection answers `200 {"received": true, "status":
"duplicate"}` and changes nothing. Inbound events are kept for **30 days**, which is also the
window in which a body-hash replay is caught. WooCommerce sends no timestamp, so for that provider
replay protection rests entirely on the body hash inside the retention window — if you need a
tighter guarantee, use the `generic` provider with your own `id`.

### Topics and what ViperFlow does with an event

| Provider | Topic taken from |
|---|---|
| `generic` | `X-ViperFlow-Event`, else `type` in the body |
| `woocommerce` | `X-WC-Webhook-Topic` |
| `shopify` | `X-Shopify-Topic` |

In v1 an inbound event is **verified, de-duplicated, stored and acknowledged**. Only the
`generic` topics `webhook.test` and `ping` have a handler (they answer `processed`); everything
else is stored with status `ignored` and reason `no_handler`. Recent events (without their
payload) are listed in the app. Business handlers per topic are the extension point for later
versions — inbound today is a verified, deduplicated inbox, not a write path into ViperFlow. Use
the [REST API](API.md) to create data.

WooCommerce's unsigned "webhook created" ping (`webhook_id=<n>` as a form body) is answered
`200 {"received": true, "status": "ignored"}` and is not stored, so saving the webhook in
WooCommerce succeeds.

### Responses

| Status | Body | Meaning |
|---|---|---|
| 200 | `{"received": true, "status": "processed"\|"ignored"\|"failed"\|"duplicate"}` | Accepted. `failed` means a handler raised — the event is stored, do **not** retry |
| 400 | `{"error": "invalid_body"}` | Body could not be read |
| 400 | `{"error": "invalid_payload"}` | Signature was valid but the payload is unusable (e.g. `generic` without `id`) |
| 401 | `{"error": "invalid_signature"}` | Signature, timestamp or scheme check failed |
| 404 | `{"error": "not_found"}` | Unknown connection id, wrong provider in the path, or the connection is disabled |
| 405 | `{"error": "method_not_allowed"}` | Only `POST` is accepted |
| 413 | `{"error": "payload_too_large"}` | Body over 512 KB |
| 429 | `{"error": "rate_limited"}` | Over 300 requests/minute for the connection, or 600/minute from one IP address. `Retry-After` is sent |
| 503 | `{"error": "unavailable"}` | Temporary; retry |
| 500 | `{"error": "internal_error"}` | Unexpected; retry |

A body over 256 KB (or one containing a NUL character) is accepted and de-duplicated, but its
payload is **not** stored — the event row is kept without it. The connection lookup happens
before the per-connection rate limit, so a wrong id costs you the per-IP bucket, not somebody
else's quota.

### Signing an outgoing request to the `generic` provider

```ts
import { createHmac } from "node:crypto";

const secret = process.env.VF_INBOUND_SECRET!;   // whsec_…
const body = JSON.stringify({ id: crypto.randomUUID(), type: "ping", data: { hello: "world" } });
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");

await fetch(process.env.VF_INBOUND_URL!, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-ViperFlow-Timestamp": timestamp,
    "X-ViperFlow-Signature": `v1=${signature}`,
    "X-ViperFlow-Event": "ping",
  },
  body,
});
```

## 16. Known limitations

- **At least once, unordered.** De-duplicate by event id; never rely on arrival order.
- **Coalescing** means one event can cover several changes, and a coalescible event is delayed by
  about 3 seconds.
- **Nothing is queued while an endpoint is disabled** — backfill with the REST API's
  `updated_since`.
- **30 days** of history for events, deliveries, attempts and inbound events.
- **7 attempts** over about 33 hours, then a manual retry is the only way forward.
- **No event filters** beyond the event type and the excluded-API-key list: no per-status or
  per-customer subscriptions.
- **No `mTLS`, no custom headers, no basic auth** on outbound deliveries. Authenticate ViperFlow
  by the signature (and, if you must, a secret path segment in the URL).
- **DNS-rebinding TOCTOU** remains theoretically possible ([§12](#12-url-rules-and-network-safety)).
- **Inbound is an inbox in v1**: events are verified, stored and acknowledged, but only
  `generic`/`webhook.test` and `generic`/`ping` are handled.
- **Inbound secrets have no rotation grace period.**
- **No document delete event**, and no events for customer statistics recalculation.
