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:
{ "error": "invalid_signature", "error_description": "GitHub webhook signature mismatch" }
What it accepts, and what it does with a push, is on 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:
{
"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:
{ "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 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:
{ "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
Authorizationheader. 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.