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

# Webhooks

> Signed event notifications for quotes, orders, files, and documents

Webhooks push signed JSON events to your endpoints as things happen — no polling.
Manage endpoints via the API (`/v1/webhook-endpoints`, scope `webhooks:manage`)
or in the [Customer Portal](https://portal.fortiseval.com) under
**Account Settings → Webhooks**, where you can also inspect recent deliveries,
retry failures, and send test events.

## Events

Subscriptions accept exact names, `*` (everything), or prefix wildcards like
`order.*`.

| Event                    | Fires when                                         |
| ------------------------ | -------------------------------------------------- |
| `quote.requested`        | A quote request was submitted                      |
| `quote.ready`            | The quote was priced and awaits your approval      |
| `quote.approved`         | The quote was approved (includes an order summary) |
| `quote.cancelled`        | The quote was cancelled                            |
| `order.created`          | An order was created from an approved quote        |
| `order.in_progress`      | Work on the order started                          |
| `order.ready_for_review` | Deliverables are ready for your review             |
| `order.completed`        | The order is complete                              |
| `order.cancelled`        | The order was cancelled                            |
| `order.refunded`         | The order was refunded                             |
| `file.ready`             | A URL-ingested file finished processing            |
| `file.failed`            | A URL-ingested file could not be processed         |
| `document.approved`      | A deliverable document was approved                |
| `ping`                   | Test event (sent from the portal or on request)    |

## Payload

Every delivery is a POST with a Stripe-style envelope. `data.object` matches the
shape the API returns for the same resource:

```json theme={null}
{
  "id": "evt_01JZX...",
  "type": "order.completed",
  "api_version": "v1",
  "created": "2026-07-08T12:00:00+00:00",
  "data": {
    "object": { "id": "ordr_...", "status": "completed", "...": "..." }
  }
}
```

Headers on every delivery:

| Header                | Contents                                                           |
| --------------------- | ------------------------------------------------------------------ |
| `X-Fortis-Signature`  | `t=<unix timestamp>,v1=<signature>` (see below)                    |
| `X-Fortis-Event`      | The event type, e.g. `order.completed`                             |
| `X-Fortis-Event-Id`   | The `evt_...` id shared by all endpoints receiving this occurrence |
| `X-Fortis-Delivery`   | Unique delivery id — use as an idempotency key                     |
| `X-Fortis-Webhook-Id` | The endpoint receiving the delivery                                |

## Verifying signatures

Each endpoint has a `whsec_...` signing secret (returned once on creation,
viewable in the portal). The `v1` signature is an HMAC-SHA256 of
`{timestamp}.{raw request body}` using that secret:

<CodeGroup>
  ```php PHP theme={null}
  function verifyFortisSignature(string $payload, string $header, string $secret): bool
  {
      if (! preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) {
          return false;
      }

      [, $timestamp, $signature] = $m;

      if (abs(time() - (int) $timestamp) > 300) {
          return false; // replay protection: reject events older than 5 minutes
      }

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

      return hash_equals($expected, $signature);
  }
  ```

  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function verifyFortisSignature(payload, header, secret) {
    const match = header?.match(/^t=(\d+),v1=([0-9a-f]{64})$/);
    if (!match) return false;

    const [, timestamp, signature] = match;
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

    const expected = createHmac("sha256", secret)
      .update(`${timestamp}.${payload}`)
      .digest("hex");

    return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  }
  ```
</CodeGroup>

<Warning>
  Compute the HMAC over the **raw request body** exactly as received — parsing and
  re-serializing the JSON will change the bytes and break verification.
</Warning>

## Delivery, retries, and failures

* Respond with any `2xx` within **10 seconds**. Do heavy processing async.
* Failed deliveries are retried up to **6 times** with increasing backoff
  (1 minute → 8 hours).
* After **20 consecutive failed deliveries**, the endpoint is automatically
  disabled. Re-enable it in the portal or via
  `PUT /v1/webhook-endpoints/{id}` with `{ "disabled": false }`.
* Delivery **order is not guaranteed**. Use the envelope's `created` timestamp,
  and fetch the resource from the API when you need its latest state.
* Deliveries may occasionally repeat — deduplicate on the `X-Fortis-Delivery`
  header.

## Endpoint requirements

* HTTPS URLs pointing at publicly reachable hosts.
* Endpoints are managed per app: an API key can only manage its own app's
  endpoints.
