curl --request POST \
--url https://app.hookie.ai/{workspace}/{project}/{webhook} \
--header 'Content-Type: application/json' \
--data '"<unknown>"'const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify('<unknown>')
};
fetch('https://app.hookie.ai/{workspace}/{project}/{webhook}', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://app.hookie.ai/{workspace}/{project}/{webhook}"
payload = "<unknown>"
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"submission_id": "<string>",
"idempotent": true
}{
"submission_id": "<string>",
"routed": [
"<string>"
],
"records": 123
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}Ingest an event through the public webhook URL
Public webhook URL, app.hookie.ai///. POST ONLY: index.ts:190 gates the route on req.method === ‘POST’, so PUT/GET/OPTIONS on this path never reach the ingest handler — they fall through to the SPA asset handler and return the console HTML, not a 405 JSON. Only 3- or 4-segment paths match.
curl --request POST \
--url https://app.hookie.ai/{workspace}/{project}/{webhook} \
--header 'Content-Type: application/json' \
--data '"<unknown>"'const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify('<unknown>')
};
fetch('https://app.hookie.ai/{workspace}/{project}/{webhook}', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "https://app.hookie.ai/{workspace}/{project}/{webhook}"
payload = "<unknown>"
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text){
"submission_id": "<string>",
"idempotent": true
}{
"submission_id": "<string>",
"routed": [
"<string>"
],
"records": 123
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>"
}Headers
Optional. If a submission already exists for this tenant with the same key, the request short-circuits with 200 before the monthly quota is charged, so retries are free.
Path Parameters
Workspace (tenant) slug. Must not be one of the reserved roots reserved for app internals (api, admin, stripe, v1, assets, settings, favicon.ico, robots.txt, sitemap.xml, index.html, app.js, styles.css, export.js, portal, brand, trial-info, .well-known, mcp) — those never reach the ingest handler.
Project slug within the workspace.
Webhook slug. This high-entropy slug IS the credential — no key, token or cookie is sent. Only an enabled webhook (enabled = 1) matches.
Body
Content-Type selects the parser: 'application/x-www-form-urlencoded' and 'multipart/form-data' are flattened into a shallow object (a field that repeats becomes an array; a file part becomes {filename, type, size} and its BYTES ARE DISCARDED; field names are kept verbatim and flat, so an input named user.email stays the key 'user.email'). ANY other Content-Type — including a missing or wrong one — is parsed as JSON. An empty body is accepted and becomes {}. A payload that is not a JSON object (array or scalar) is wrapped as {"value": } before it is stored as a record. Bodies over 1,000,000 bytes are rejected with 413.
Any JSON value. Objects are stored field-for-field; arrays and scalars are wrapped as {value: }.
Response
Idempotent replay — a submission with this Idempotency-Key already exists for the tenant. No quota is charged when the duplicate is detected up front; a request that loses the unique-index race has already been counted against the monthly quota.