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

# Webhooks

Nine routes on the gateway exist to be called by something that is not you: GitHub, OpenMetadata,
Unipile, Bland and fal.ai. None of them takes an `Authorization: Bearer` credential, because none of
those senders holds one.

That makes "how is this authenticated" the only question worth answering carefully, and the answer
is different for each group. This page states it per route, and is explicit about where the check
happens — because on six of these routes the gateway performs no authenticity check at all and
forwards the request onward.

No secret appears on this page. Every value shown is a placeholder or a redaction.

## The four mechanisms at a glance

| 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 — there is no pin segment | downstream, by re-fetching |
| `POST /blandai/{triggerID}` | nothing | downstream, by trigger lookup |
| `POST /falai/{triggerID}` | nothing | downstream, by trigger lookup |

The bottom six rows are worth a second read: those routes are reachable by anyone who can reach your
gateway, and the gateway will forward what they send.

## Signature-verified: GitHub

`POST /v1/codesync/webhook/github`

GitHub signs each delivery with HMAC-SHA256 over the exact request body, using the secret you
configured on the repository's webhook, and sends it as:

```
X-Hub-Signature-256: sha256=<hex digest>
```

The gateway recomputes the digest over the bytes it received — before parsing — and compares in
constant time. A mismatch, or a missing header, is refused:

```json
{ "error": "invalid_signature", "error_description": "GitHub webhook signature mismatch" }
```

What it accepts, and what it does with a push, is on [Code sync](/docs/api/code-sync).

## Signature-verified: OpenMetadata

`POST /v1/catalog/om/webhook`

OpenMetadata alerts deliver here so the catalog refreshes within seconds of a change rather than at
the next scheduled synchronisation. The signature scheme mirrors GitHub's — HMAC-SHA256 over the raw
body — but the header is `X-OM-Signature`, and the secret is the one set on the alert in
OpenMetadata.

The body is a single event, or an array of them:

```json
{
  "eventType": "entityUpdated",
  "entityType": "table",
  "entityId": "…",
  "entityFQN": "warehouse.public.orders",
  "changeType": "schemaChange",
  "timestamp": 1789469476000
}
```

The response is `202 {"status":"accepted"}`, sent **before** any synchronisation runs — the work is
scheduled in the background, and a burst of events is coalesced into one run after a quiet window
rather than triggering one run per event. Acknowledging immediately is what stops OpenMetadata
retrying while a long synchronisation is still going.

Events that change what the catalog knows trigger a run; identity and operations noise (users,
teams, bots, roles, policies, test cases) is ignored.

## Header-verified: the internal catalog ingest

`POST /v1/internal/catalog/ingest`

Not a third-party callback, and included here only because it looks like one in a network trace. It
is the service-to-service ingest path, authenticated by a shared `x-server-key` header that Wexa's
own services hold. Anything else is refused:

```json
{ "error": "unauthorized", "error_description": "valid x-server-key required" }
```

That was the live response both with no header and with a wrong one — the two are indistinguishable,
deliberately. Use `POST /v1/catalog/ingest` with a credential instead;
[Catalog](/docs/api/catalog) covers it.

## Path-secret: the three Unipile notify routes

```
POST /actions/linkedin/notify/{connectorID}/{pin}
POST /actions/mail/notify/{connectorID}/{pin}
POST /actions/whatsapp/notify/{connectorID}/{pin}
```

Unipile calls these once a person finishes connecting an account through hosted authentication. It
sends no credential and signs nothing, so the only secret in the request is the URL itself: the
`{pin}` segment is issued when the connector is created and is meant to be unguessable.

What survives the hop is narrow, and deliberately so. The body is passed through **byte for byte**
rather than re-encoded, because hosted-authentication results arrive as JSON and trigger events
arrive form-encoded, and re-serialising would corrupt half the traffic. `Content-Type` is copied so
the downstream parser sees the right format. **No other inbound header is copied** — an
`Authorization` header sent to one of these routes is dropped and cannot influence anything
internally. The first two were checked from the receiving side; the header claim is read from the
handler, which sets `Content-Type` on the outbound request and nothing else.

The downstream check is real: the hosted-authentication handler re-fetches the reported account from
Unipile before trusting it, so a forged account id fails there.

## No credential at all: the Unipile webhook, Bland and fal.ai

```
POST /unipile-webhook/{projectID}/{connectorID}
POST /blandai/{triggerID}
POST /falai/{triggerID}
```

These three carry nothing to verify, and — unlike the notify routes above — not even a pin.
`/unipile-webhook/{projectID}/{connectorID}` is addressed entirely by ids that appear elsewhere in
the system.

The gateway's role is the same as for the notify routes: prefix check, body passed through
unaltered, `Content-Type` copied, nothing else. A form-encoded body sent to the Unipile webhook
arrived downstream as the identical byte string. The upstream's status code is mirrored back so the
sender's own retry logic sees the real verdict — and a failure to reach the downstream service is
`502`, not `500`, so a transient blip still converges on retry:

```json
{ "error": "upstream_unreachable", "error_description": "…: connection refused" }
```

Authenticity is established downstream. The Unipile webhook receiver re-reads the connector's
current trigger configuration before running anything; the Bland and fal.ai receivers resolve the
trigger id against their own registration records, and an unknown id is a `404` there.

## What none of these routes do

- **None of them reads your `Authorization` header.** Sending a credential does not improve your
  odds and does not reach the downstream service.
- **None of them is a general-purpose proxy.** Each handler re-checks the path prefix before
  forwarding, so a future registration mistake cannot turn one into a tunnel into the private
  network.
- **None of them returns your data.** The response body is the downstream service's, which for these
  receivers is an acknowledgement.

## Per-route ledger

| Route | Result |
|---|---|
| `POST /v1/codesync/webhook/github` | `401` unsigned with a secret set; `200` correctly signed; `401` wrong signature; `200` unsigned with no secret set; `200 ignored` for a non-push event; `404 repo_not_bound` for an unbound push |
| `POST /v1/catalog/om/webhook` | `503` — handler not configured on the tested deployment |
| `POST /v1/internal/catalog/ingest` | `401` with no header and with a wrong one |
| `POST /actions/linkedin/notify/{connectorID}/{pin}` | forwarded; downstream response returned |
| `POST /actions/mail/notify/{connectorID}/{pin}` | forwarded with a deliberately wrong pin; downstream response returned |
| `POST /actions/whatsapp/notify/{connectorID}/{pin}` | forwarded; downstream response returned |
| `POST /unipile-webhook/{projectID}/{connectorID}` | forwarded; form-encoded body intact downstream |
| `POST /blandai/{triggerID}` | `502` — no receiver running |
| `POST /falai/{triggerID}` | `502` — no receiver running |

The provider pin is not checked and the body is forwarded byte for byte; no other inbound header is copied.
