API reference
The /ingest/v1 HTTP API — authentication, every endpoint, call shapes, batching, response codes, rate limits, and backfill.
Everything under https://app.airhop.ai/ingest/v1 accepts product events. The API follows the Segment spec (identify, track, page, group), requests are validated and queued immediately, and the heavy lifting happens asynchronously — so responses come back fast even for large batches. This page is the wire-level reference; for the guided version, see Send product events.
Authentication
Every request must carry an ingestion key. Three forms are accepted:
| Form | Example |
|---|---|
| Bearer token (recommended) | Authorization: Bearer wk_your_write_key |
| HTTP Basic, key as username | curl -u wk_your_write_key: ... (empty password) |
writeKey in the body or query string | { "writeKey": "wk_...", "batch": [...] } or ?writeKey=wk_... |
The Basic and writeKey forms exist for tooling that only supports them; prefer the Bearer header everywhere else — including a Segment Webhooks (Actions) destination, where you add Authorization: Bearer sk_... as a request header on the Send mapping.
Two keys authenticate:
- The write key (
wk_...) is write-only and safe to ship in client-side code. You will find it behind the Install snippet button on the Activation page. - The secret key (
sk_...) is for servers only — never ship it to a browser.
Both keys ingest events, but only secret-key traffic is trusted. Because the write key is visible in your page source by design, anyone could forge calls with it — so write-key traffic gets two safety restrictions: an identify can set an email on a contact that has none, but never replace one already on file, and any playbook it triggers is drafted for your approval instead of auto-sent. The one exception is an in-widget nudge, which still sends immediately — it only surfaces inside your own widget, to the user already looking at it, so there is nothing to forge into a customer's inbox. Send from your server with the secret key when you want full trust.
A missing or unknown key returns 401.
The call object
Every call is a JSON object. Fields common to all types:
| Field | Type | Notes |
|---|---|---|
type | string | identify, track, page, screen, or group. Optional on the typed endpoints (implied by the URL); required inside batch. |
userId | string | Your stable user id. A call needs userId or anonymousId to be attributable. |
anonymousId | string | Pre-login visitor id. The browser SDK manages one automatically; events sent with only an anonymousId stay unlinked until an identify merges them onto the user. |
timestamp | string | ISO-8601. When the event actually happened — essential for backfills. Missing timestamps default to receipt time. originalTimestamp and sentAt are accepted as fallbacks. |
messageId | string | Unique id for this call; the deduplication key (see Idempotency). Generated server-side if omitted. |
context | object | Metadata about how the call was collected (library, page URL). Stored with the event. |
Per-type fields:
| Type | Field | Notes |
|---|---|---|
identify | traits | object — email, name, plan, and any custom traits. Email is what links the user to an account. |
track | event | string, required — a track with no event name is dropped. Use stable snake_case names. |
track | properties | object — anything about the action. |
page / screen | name | string — defaults to $page / $screen. properties also accepted. |
group | groupId | string — the account (company) id to link the user to. traits carries account traits like name. |
Endpoints
POST /ingest/v1/identify
curl https://app.airhop.ai/ingest/v1/identify \
-H "Authorization: Bearer wk_your_write_key" \
-H "Content-Type: application/json" \
-d '{
"userId": "user_123",
"traits": { "email": "jo@acme.com", "name": "Jo Diaz", "plan": "pro" }
}'POST /ingest/v1/track
curl https://app.airhop.ai/ingest/v1/track \
-H "Authorization: Bearer wk_your_write_key" \
-H "Content-Type: application/json" \
-d '{
"userId": "user_123",
"event": "project_created",
"properties": { "projectId": "p_1" }
}'POST /ingest/v1/page
curl https://app.airhop.ai/ingest/v1/page \
-H "Authorization: Bearer wk_your_write_key" \
-H "Content-Type: application/json" \
-d '{
"userId": "user_123",
"name": "Pricing",
"properties": { "url": "https://acme.com/pricing" }
}'POST /ingest/v1/group
curl https://app.airhop.ai/ingest/v1/group \
-H "Authorization: Bearer wk_your_write_key" \
-H "Content-Type: application/json" \
-d '{
"userId": "user_123",
"groupId": "acme_inc",
"traits": { "name": "Acme Inc" }
}'POST /ingest/v1/batch
Any mix of call types, each with an explicit type, up to 5,000 calls per request:
curl https://app.airhop.ai/ingest/v1/batch \
-H "Authorization: Bearer wk_your_write_key" \
-H "Content-Type: application/json" \
-d '{
"batch": [
{ "type": "identify", "userId": "user_123", "traits": { "email": "jo@acme.com" } },
{ "type": "track", "userId": "user_123", "event": "project_created" },
{ "type": "group", "userId": "user_123", "groupId": "acme_inc" }
]
}'Ordering within a batch does not matter; milestone timing is computed from each call's timestamp, not arrival order.
CDP adapters
Three additional endpoints accept other tools' native payloads and normalize them into the same pipeline:
| Endpoint | Accepts |
|---|---|
POST /ingest/v1/segment | A Segment Webhooks (Actions) destination — single calls or { "batch": [...] }, unchanged. |
POST /ingest/v1/posthog | PostHog's event shape (the event uuid becomes the messageId). |
POST /ingest/v1/intercom | Intercom Data Events (the event id becomes the messageId). |
Authentication and limits are identical to the native endpoints. See Send product events for the destination setup.
Responses
| Status | Body | Meaning |
|---|---|---|
200 | { "received": 2, "backfill": false } | Accepted and queued. received is the number of calls taken off the request; per-call validation happens later, so a malformed call (say, a track with no event) is counted here but silently dropped during processing. |
401 | { "message": "Invalid ingestion key." } | Missing or unknown key. Check you are sending the wk_ write key, not the widget's pk_ public key. |
413 | { "error": "batch too large" } | More than 5,000 calls in one request. Split the batch. |
429 | — | Rate limit exceeded. Back off and retry. |
Rate limits
6,000 requests per minute, per key. The limit counts requests, not calls — each request can carry a 5,000-call batch, so the practical ceiling is millions of events per minute. It is a guard against runaway clients, not a constraint on legitimate volume. If you hit 429, batch more aggressively rather than retrying faster.
Idempotency
Give every call a messageId that is stable across retries. Ingestion deduplicates on the combination of workspace, messageId, and timestamp — a duplicate is silently ignored, so re-sending a batch that failed halfway is always safe. If you omit messageId, one is generated per call and every retry counts as a new event.
{ "type": "track", "userId": "user_123", "event": "project_created", "messageId": "pc-p_1" }Backfill
Mark a request as a historical import with either:
"backfill": trueat the top level of the request body, or- the header
X-Airhop-Backfill: 1.
Backfilled calls record milestones and health but never fire playbooks or count billable outcomes — importing a year of history won't send a year of emails. The response echoes the flag ("backfill": true). See Backfill historical data for the full recipe.
A reliable server-side sender is small: batch up to 5,000 calls, set a stable messageId on each, send with your sk_ secret key, and retry failed requests with backoff. Idempotency makes the retries free.