> Source: https://wexa.ai/docs/automation/webhooks

# Webhooks

A webhook makes Wexa send a signed JSON `POST` to a URL you choose each time an ontology in your project is committed, updated or discarded.

Anyone who can reach the project's ontology management in the console can create one; the handler checks no role of its own, and which roles reach it is not stated in source. You manage webhooks in the console; no SDK method or public API route wraps them today.

## Delivery contract

| | |
|---|---|
| Method and body | `POST`, `Content-Type: application/json`, one event per request |
| Headers | `X-Signature-SHA256` (the signature) and `X-Wexa-Event` (the event name) |
| Timeout | 15 seconds per delivery |
| Status to return | Below `300` counts as success. Anything else, or no answer in time, is logged as a failure |
| Retries | None. One attempt per event; see [Retries](#retries) |
| HTTPS only | Not stated. Source does not check the URL's scheme; use HTTPS anyway |

Wexa does not wait for your answer before carrying on. Delivery runs in the background after the change is saved, so a slow or failing receiver never blocks the edit that triggered it.

## Create a webhook in the console

1. In the console sidebar, open **Connectors** and choose the **Ontology** tab at the top of the page.
2. In the ontology management bar, choose the **Webhooks** tab, then select **Add Webhook**.
3. Optionally enter a **Name**; it defaults to `Webhook`.
4. Enter the destination URL, for example `https://example.com/hooks/fabric`. It is required for every event except **Inbound**.
5. Tick the events you want: **Committed**, **Updated** or **Discarded**. Committed is ticked by default. The **Inbound** option makes the webhook an ingest endpoint instead; that endpoint is not on the public gateway, so this page does not cover it.
6. Leave **Rate limit (per min)** at its default of `60` unless you need otherwise. It applies only to inbound requests; deliveries to your URL are not throttled.
7. Save. A green banner shows the secret, once. Copy it now: afterwards the row shows only its last four characters, as `secret_hint`.

Each webhook is a row in the Webhooks tab with an **active** or **paused** pill. **Pause** stops deliveries, **Resume** restarts them, and the bin icon deletes the webhook. Expand a row to see **Recent deliveries**: the event, status code, duration, any error and the time of each attempt.

## Payloads

Every delivery has the same envelope. Keys arrive in alphabetical order, because the body is serialised from a map:

| Field | Meaning |
|---|---|
| `event` | The event name, also sent in `X-Wexa-Event` |
| `project_id` | The project the change belongs to |
| `timestamp` | When Wexa built the payload: UTC, RFC 3339, whole seconds |
| `data` | The event fields below |

### `ontology.committed`

Sent the first time a proposal is committed as an ontology.

| `data` field | Meaning |
|---|---|
| `proposal_id`, `proposal_name` | The proposal that was committed |
| `ontology_id` | The ontology it created |
| `recommit` | `false` |
| `nodes`, `relationships` | How many of each the ontology has |

```json
{"data":{"nodes":12,"ontology_id":"ont_customers","proposal_id":"6650a1f2c3d4e5f607182930","proposal_name":"Customers","recommit":false,"relationships":9},"event":"ontology.committed","project_id":"proj_example","timestamp":"2026-11-03T09:30:00Z"}
```

### `ontology.updated`

Sent from four places, and the `data` fields depend on which:

| Cause | `data` fields |
|---|---|
| A committed proposal is committed again | `proposal_id`, `proposal_name`, `ontology_id`, `recommit` (`true`), `nodes`, `relationships` |
| An ontology is edited directly | `ontology_id`, `ontology_name`, `scope`, `nodes`, `relationships` |
| A proposal is edited | `proposal_id`, `proposal_name` |
| An edit is undone | `ontology_id`, `ontology_name`, `undo_of` (the action that was undone) |

```json
{"data":{"ontology_id":"ont_customers","ontology_name":"Customers","scope":"project"},"event":"ontology.updated","project_id":"proj_example","timestamp":"2026-11-03T09:41:07Z"}
```

### `ontology.discarded`

Sent when an ontology is deleted from the graph.

| `data` field | Meaning |
|---|---|
| `ontology_id`, `ontology_name` | The ontology that was removed |

### `ontology.inbound`

Never delivered to you. Choosing it turns the webhook into an ingest endpoint instead, which this page does not cover.

## Verify a delivery

Wexa signs the exact bytes of the request body with HMAC-SHA256, keyed with your webhook's secret, and sends the result as:

```
X-Signature-SHA256: sha256=<64 hex characters>
```

The key is the secret string as shown, all 64 characters used as text, not decoded from hex. Compute the digest over the raw body before you parse it: re-serialising JSON changes the bytes, and Wexa's serialiser escapes `<`, `>` and `&`.

The signature carries no timestamp and Wexa states no tolerance window. The `timestamp` field is inside the signed body, so it cannot be altered without breaking the signature; whether to reject old ones, and how old, is your policy. Wexa also sends no delivery id, so you cannot de-duplicate on one.

Test vector. With secret `9f2c41d7b8a35e60c1d4f7a92b3e8c05a6d1f4e7b2c93a58d0e6f1b7c4a29d83` and the `ontology.committed` body above, the header is `sha256=26672933a945a5ba051f27f73b07bef24353785811b1922866a6b95c10f36779`.

**Verify X-Signature-SHA256**

**Python**

```python
import hashlib
import hmac

def verify(raw_body: bytes, secret: str, header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header)
```

**TypeScript**

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

export function verify(rawBody: Buffer, secret: string, header: string): boolean {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

**Plain HTTP (shell)**

```bash
# BODY is the request body exactly as received; SECRET is the webhook secret.
printf %s "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/.*= /sha256=/'
# Compare the output with the X-Signature-SHA256 header.
```

The shell comparison is not constant-time; use it for testing only, and use the Python or TypeScript version in a receiver.

Respond with any status below `300` once the signature checks out, and answer a bad signature with `401`; Wexa only records the code.

## Retries

Wexa does not retry. Each event is delivered once, and a failure is written to the webhook's delivery log (status code, duration and error) and nothing else; there is no schedule, no backoff and no dead-letter queue. The log keeps the latest 500 deliveries per webhook. If your receiver was down, read the log, then re-read the ontology; events are not replayed.

## Limits and behavior

| | |
|---|---|
| Fan-out budget | One 30-second budget covers every webhook an event is delivered to, so a slow receiver can delay the ones after it |
| Delivery order | Not guaranteed; each event is delivered independently |
| Secret | 64 hex characters, shown once; no rotation is offered |
| Delivery log | The latest 500 per webhook are kept; the console shows the newest 100 |
| Pause | A paused webhook receives nothing |

**Callbacks Wexa receives**

### Callbacks Wexa receives

Nine other routes on the gateway exist to be called by GitHub, OpenMetadata, Unipile, Bland and fal.ai. None takes an `Authorization: Bearer` credential, because none of those senders holds one, so how each is authenticated, and where, differs.

| Route | Authenticated by | Checked by |
|---|---|---|
| `POST /v1/codesync/webhook/github` | HMAC-SHA256 over the raw body, `X-Hub-Signature-256` | the gateway |
| `POST /v1/catalog/om/webhook` | HMAC-SHA256 over the raw body, `X-OM-Signature` | the gateway |
| `POST /v1/internal/catalog/ingest` | shared `x-server-key` header | the gateway |
| `POST /actions/linkedin/notify/{connectorID}/{pin}` | an unguessable `{pin}` segment | downstream, not the gateway |
| `POST /actions/mail/notify/{connectorID}/{pin}` | an unguessable `{pin}` segment | downstream, not the gateway |
| `POST /actions/whatsapp/notify/{connectorID}/{pin}` | an unguessable `{pin}` segment | downstream, not the gateway |
| `POST /unipile-webhook/{projectID}/{connectorID}` | nothing | downstream, by re-fetching |
| `POST /blandai/{triggerID}` | nothing | downstream, by trigger lookup |
| `POST /falai/{triggerID}` | nothing | downstream, by trigger lookup |

- **GitHub.** The gateway recomputes `sha256=<hex>` over the bytes it received, before parsing, and compares in constant time. A mismatch or missing header is `401 {"error":"invalid_signature","error_description":"GitHub webhook signature mismatch"}`; a non-push event is `200 ignored`; a push for an unbound repository is `404 repo_not_bound`; with no secret configured, unsigned requests are accepted. What a push does is on [Code sync](/docs/context/code-sync-api).
- **OpenMetadata.** The body is one event or an array. The gateway answers `202 {"status":"accepted"}` before any synchronisation, coalesces a burst into one run, and ignores identity and operations noise (users, teams, bots, roles, policies, test cases). `503` means the handler is not configured on that deployment.
- **Internal catalog ingest.** Service-to-service, authenticated by the `x-server-key` header. A missing and a wrong key both give `401 {"error":"unauthorized"}`. Call `POST /v1/catalog/ingest` with a credential instead; see [Catalog](/docs/connectors/catalog).
- **Notify, Unipile, Bland and fal.ai routes.** The gateway checks the path prefix, passes the body through byte for byte, copies `Content-Type` and copies no other inbound header, so an `Authorization` header sent here is dropped. The upstream status is mirrored back; an unreachable upstream is `502 upstream_unreachable`. Authenticity is established downstream: hosted authentication re-fetches the reported account from Unipile, the Unipile webhook receiver re-reads the connector's trigger configuration, and the Bland and fal.ai receivers look the trigger id up, answering `404` for an unknown one.

On the routes without a gateway check, anyone who can reach your gateway can send a request and the gateway will forward it. None of these routes returns your data, and none is a general proxy: each re-checks its path prefix before forwarding.

### What's next

- [Schema and ontology](/docs/context/create-ontology#change-your-project-ontology): the ontologies whose changes these webhooks report.
- [Authentication](/docs/get-started/authentication): how every other route is authenticated, and why these nine are the exception.
- [Errors and retries](/docs/get-started/errors-and-retries): what to do when a call you make fails.
