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

# Webhooks

> Receive signed push notifications on every status change

If you supply `notification_url` on `POST /v1/payouts`, the provider POSTs a
signed event to that URL **on every status transition** — `PENDING`,
`PROCESSING`, `PAID`, `REJECTED`, `CANCELLED`. No polling required.

<Warning>
  Sandbox mock webhooks are **fire-and-forget**. A non-2xx response or a timeout
  is logged at `WARNING` and **not retried**. The production enviroment will add
  at-least-once delivery with retries; for now, treat webhooks as a
  "nice push hint" and back them with `GET /v1/payouts/{id}` polling for
  correctness.
</Warning>

## Subscribing

Pass `notification_url` on create. **HTTPS only** — `http://` URLs are
rejected with `422 VALIDATION_ERROR`.

```json theme={null}
{
  "amount": "4600000.00",
  "currency": "COP",
  "country": "CO",
  "external_id": "payroll-co-2026-05-emp-001",
  "notification_url": "https://client.example.com/webhooks/clevis-payouts",
  "beneficiary": { ... }
}
```

The webhook fires on every transition the payout goes through, so a
happy-path payout produces three events: `PENDING`, `PROCESSING`, `PAID`.

## Event shape

```jsonc theme={null}
// POST https://client.example.com/webhooks/clevis-payouts
// Headers:
//   Content-Type: application/json
//   X-Clevis-Signature: sha256=<hex HMAC-SHA256 of the raw body>
//   X-Clevis-Mock: true
{
  "type": "payout.status_changed",
  "id": "evt_01JABC...",
  "created_at": "2026-05-12T15:04:07Z",
  "previous_status": "PENDING",
  "data": {
    // The full Payout resource at the NEW state.
    "id": "po_01J...",
    "object": "payout",
    "mock": true,
    "status": "PROCESSING",
    "status_history": [ ... ],
    // ... every other Payout field
  }
}
```

`data` is the full `Payout` resource after the transition — same shape as
`GET /v1/payouts/{id}`. Use `data.status` and `previous_status` to drive
your handler.

## Signature verification

Every webhook is signed with HMAC-SHA256 over the raw response body, using
your webhook secret. The signature is sent in the `X-Clevis-Signature` header
as:

```
X-Clevis-Signature: sha256=<lowercase hex digest>
```

**Verify the signature before trusting any field on the event.** Use a
constant-time comparison (`hmac.compare_digest` in Python, `crypto.timingSafeEqual`
in Node.js) to avoid timing attacks.

<CodeGroup>
  ```python Python (FastAPI) theme={null}
  import hmac
  import hashlib
  import os
  from fastapi import FastAPI, Request, HTTPException

  app = FastAPI()
  SECRET = os.environ["CLEVIS_WEBHOOK_SECRET"].encode()

  @app.post("/webhooks/clevis-payouts")
  async def handle(request: Request):
      raw = await request.body()
      sent = request.headers.get("X-Clevis-Signature", "")
      if not sent.startswith("sha256="):
          raise HTTPException(400, "Missing or malformed signature")

      expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(sent, expected):
          raise HTTPException(401, "Bad signature")

      event = await request.json()
      payout = event["data"]
      print(
          f"{payout['id']}: {event['previous_status']} -> {payout['status']}"
      )
      return {"ok": True}
  ```

  ```javascript Node.js (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const SECRET = process.env.CLEVIS_WEBHOOK_SECRET;

  // IMPORTANT: capture the raw body before JSON parsing — the signature is
  // computed over the exact bytes that were sent.
  app.post(
    "/webhooks/clevis-payouts",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const sent = req.header("X-Clevis-Signature") || "";
      if (!sent.startsWith("sha256=")) {
        return res.status(400).send("Missing or malformed signature");
      }

      const expected =
        "sha256=" +
        crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");

      if (
        sent.length !== expected.length ||
        !crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected))
      ) {
        return res.status(401).send("Bad signature");
      }

      const event = JSON.parse(req.body.toString("utf8"));
      const payout = event.data;
      console.log(
        `${payout.id}: ${event.previous_status} -> ${payout.status}`
      );
      res.json({ ok: true });
    }
  );
  ```
</CodeGroup>

<Tip>
  Compute the HMAC over the **raw body bytes**, not the parsed-and-restringified
  JSON. In Node.js with Express that means `express.raw()`; in Python with
  FastAPI, `await request.body()` before reading JSON.
</Tip>

## Delivery semantics in v1

| Property    | v1 (mock)                            | Real provider (future)   |
| ----------- | ------------------------------------ | ------------------------ |
| Delivery    | At-most-once                         | At-least-once            |
| Retries     | **None**                             | Exponential backoff      |
| Timeout     | 5 seconds (configurable)             | 5 seconds (configurable) |
| Order       | Best effort                          | Best effort              |
| Mock marker | `X-Clevis-Mock: true` always present | Header dropped           |

**Always** back your webhook handler with `GET /v1/payouts/{id}` reconciliation
on a timer — in v1 because there are no retries, and in the long term to handle
out-of-order delivery and missed events from any push system.

## Mock-only knobs

* `processing.step_seconds: 0` on create — no webhooks fire, because all
  transitions are applied synchronously **before** the create response is
  returned. The terminal state is already in the response body.
* `processing.step_seconds: 30` (or any non-zero value) — webhooks fire on
  every transition, separated by that many seconds.

## Testing locally

A common pattern: point `notification_url` at an [ngrok](https://ngrok.com)
or [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/) URL
running on your machine, and use a `MOCK_SLOW_` `external_id` to make the
transitions visible at human pace.
