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,
"deduplicated_by": "idempotency-key"
}{
"submission_id": "<string>",
"routed": [
"<string>"
],
"records": 123,
"filtered": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "quota_exceeded",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true,
"limit": 123,
"reset_at": "2023-11-07T05:31:56Z"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "endpoint_disabled",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}Ingest an event through the public webhook URL
Public webhook URL, app.hookie.ai///. POST, plus OPTIONS (the CORS preflight) and a GET carrying hub.challenge (Meta’s URL verification); any other GET, and PUT, fall through to the console and never reach ingest. Only 3- or 4-segment paths match. Every stored submission keeps the request’s method, a filtered header map, the query string, the Content-Type and the source IP (ING-3, #193), shown on the record (GET …/records/) and on correlate as request. Credentials are never stored: Authorization, Proxy-Authorization, Cookie and any header or query parameter named like a credential (key, api-key, token, secret, password, session, auth) is stored as “[redacted]”; Cloudflare and proxy hop headers (cf-, x-forwarded-, x-real-ip, true-client-ip, cdn-loop, host) are dropped. HTML forms (ING-8, #193): when the endpoint has a redirect_url, a browser’s native form POST (a form encoding with Sec-Fetch-Mode: navigate, or with an Accept that asks for text/html from an older browser) is answered 303 See Other to that URL once stored (a deduplicated retry too); fetch() calls and servers still get the JSON. CORS: when the request’s Origin is in the endpoint’s cors_origins (or they include ”*”), every response carries Access-Control-Allow-Origin, Access-Control-Expose-Headers: Retry-After and Vary: Origin. URL verification (ING-10, #193): an endpoint whose handshake is slack, graph, zoom or twitch answers that provider’s challenge with 200 before anything is stored or counted - Slack’s url_verification body and Twitch’s webhook_callback_verification (Twitch-Eventsub-Message-Type header) with the challenge as text/plain, Microsoft Graph’s ?validationToken= with the token as text/plain, and Zoom’s endpoint.url_validation with (HMAC-SHA256 of plainToken under the endpoint’s secret token). Hookie Forms (#341): an endpoint with a honeypot answers a submission that fills it 201 and drops it; one with a Turnstile secret refuses a submission without a valid cf-turnstile-response token 403 (reason turnstile_failed); one with form_fields refuses a payload below them 422 (reason form_invalid, fields: []); all three before the quota and the store. A browser’s native form POST to an endpoint with no redirect_url is answered with an HTML thank-you page (200), or, for a 422 or a Turnstile refusal, an HTML page saying what to fix. Every other request is an ordinary event.
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,
"deduplicated_by": "idempotency-key"
}{
"submission_id": "<string>",
"routed": [
"<string>"
],
"records": 123,
"filtered": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "quota_exceeded",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true,
"limit": 123,
"reset_at": "2023-11-07T05:31:56Z"
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "endpoint_disabled",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}{
"error": "<string>",
"retry_after": 123,
"upgrade_url": "<string>",
"reason": "<string>",
"code": "agent_scope_insufficient",
"required_scope": "hookie:read",
"granted_scopes": [
"<string>"
],
"manage_url": "<string>",
"scheme": "<string>",
"max_members": 123,
"members": 123,
"customer_id": "<string>",
"customer_name": "<string>",
"account_id": "<string>",
"terms_version": "<string>",
"claim_required": true
}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. A disabled webhook answers 503 rather than 404.
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 body is parsed as JSON first, whatever its Content-Type (a missing or wrong one included). A body that is not JSON is kept as text when its Content-Type is textual: text/* (plain, csv, xml, ...), application/xml or any +xml type, NDJSON / JSON Lines (application/x-ndjson, application/ndjson, application/jsonl, application/x-jsonlines), application/csv and YAML. It is stored as one event, {"body": "<the text, verbatim>", "content_type": "<media type, lowercased, parameters stripped>"}, decoded as UTF-8; NDJSON is not split into several events. A body that does parse as JSON is stored as JSON whatever its Content-Type, exactly as before, so a provider that posts JSON as text/plain still gets an object. A non-JSON body that declares JSON (or no Content-Type) is 400, and one of any other type (application/octet-stream, image/*, ...) is 415. 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
Duplicate - this endpoint (or, on /v1/ingest, this ingest key) already stored a submission with the same dedup key, so the retry is answered with the first submission's id and nothing is stored or counted (ING-9, #193). The dedup key is, in order: an Idempotency-Key header; else a provider delivery id kept the same across that provider's retries - webhook-id (Standard Webhooks), svix-id, X-GitHub-Delivery, X-Shopify-Webhook-Id, X-Gitlab-Event-UUID, I-Twilio-Idempotency-Token, Linear-Delivery, Twitch-Eventsub-Message-Id, X-Atlassian-Webhook-Identifier; else the body field the endpoint's dedupe_path names, when that field holds a non-empty string or a number (HK-006); else Stripe's event id (a body with object 'event' and an evt_ id) or Slack's event_id (type 'event_callback'). The key space is the ENDPOINT's, not the workspace's: two endpoints may receive the same key. deduplicated_by names which source matched. A request that loses the unique-index race has already been counted against the monthly quota. ALSO 200 for an answered URL-verification challenge (ING-10): the challenge as text/plain, or Zoom's JSON {plainToken, encryptedToken}. Nothing is stored or counted.
id of the submission stored by the first request
true Which dedup source matched.
idempotency-key, standard-webhooks, svix, github, shopify, gitlab, twilio, linear, twitch, atlassian, stripe, slack