Skip to main content
A webhook function makes Keebai’s infrastructure send an HTTPS request on your behalf, with arguments the model chose, to a host you configured. Two consequences follow, and this page is about both.

Which URLs are rejected

Every create and every update that touches context.url runs the same validator. A rejection is a 400 with error.code of FUNCTION_URL_NOT_ALLOWED and a details.reason naming the rule. Alternate encodings of an address are normalised before the check, so https://0x7f000001/, https://2130706433/, https://127.1/ and https://[::ffff:127.0.0.1]/ are all rejected as not_globally_routable just like https://127.0.0.1/. timeout is separately bounded to 1–30 seconds; outside that it is a 400 with FUNCTION_TIMEOUT_NOT_ALLOWED.

Placeholders in the host are the interesting case

{{name}} placeholders are a real feature: they are filled at call time from the arguments the model picked, which is what makes https://erp.example.com/stock/{{sku}} work.
That substitution happens at call time, with values the model chose. A URL like https://{{host}}/webhook would pass every static address check above and then hand the choice of destination to the model. That is why placeholders are allowed in the path, the query string and the body, and rejected in the host and the port.

What this does not protect against

The validator inspects the URL you stored. It resolves nothing.
A hostname that points at a public address today can point at a private one tomorrow, and the name is resolved again when the request is actually made. Closing that gap is a matter of controlling egress, not of validating input, so treat this validator as a guard against the accidental and the casual — a copy-pasted http://localhost:3000, a cloud metadata endpoint, an internal service name — and not as a boundary against a determined attacker who already holds a write-scoped token for your company.

Secrets are write-only

context.headers and context.params are where webhook credentials live in practice: a bearer token, an API key, a signed query parameter. Reads mask them. Keys stay visible so you can see the shape of the request; every value comes back as the literal string ***.
context.body is not masked. It is the request template the model fills in, not a place credentials normally live, and hiding it would remove the main reason to read a function at all.

Writing them back

The masking creates one hazard, and the API refuses rather than let you walk into it. The third row is the one that matters. The obvious client pattern — GET, change one field, PUT the whole object back — would otherwise store the literal string *** over a live credential, and the function would start failing with a 401 from your own service for no visible reason. Instead it fails loudly and immediately. So to change the URL and keep the credentials, omit headers:
And to rotate a credential, send the new value:
Updates are read-modify-write, so concurrent writers can lose each other’s changes. Merging the stored headers requires reading them first; two updates to the same function that overlap will leave whichever finished last. Serialise updates to one function.

Only webhook functions are exposed

Keebai has other kinds of function — calendar integrations, ecommerce lookups, the ones generated automatically from a product_info block. None of them appear here. GET /v1/functions filters to webhooks, and asking for a non-webhook by id returns 404 rather than 403, so an id you cannot manage is indistinguishable from one that does not exist. Because auto-generated functions are never of type webhook, they fall outside this surface by construction: there is no way to create one, re-point one at a different block, or delete one through this API.

Next steps

Create a function

The full body, with parameters and placeholders.

Update a function

The merge rules, in detail.