Skip to main content
POST
Ingest an event through the public webhook URL

Headers

Idempotency-Key
string

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
string
required

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
string
required

Project slug within the workspace.

webhook
string
required

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.

submission_id
string
required

id of the submission stored by the first request

idempotent
enum<boolean>
required
Available options:
true
deduplicated_by
enum<string>

Which dedup source matched.

Available options:
idempotency-key,
standard-webhooks,
svix,
github,
shopify,
gitlab,
twilio,
linear,
twitch,
atlassian,
stripe,
slack