Server to server
Not everything worth measuring happens in a browser or an app. A subscription renews, a webhook fires, a nightly job decides someone churned — no client is running, so no SDK can send it. The same POST /ingest endpoint accepts those events directly when you present a write-scope token, and /api/v1 hands the numbers back the same way.
When to use it
| If you need | Do this |
|---|---|
| Billing / subscription events | Relay the store or provider webhook (RevenueCat, Stripe) to POST /ingest with a write token — see subscription events |
| Events with no client present | Cron jobs, queue workers, refunds, moderation decisions, server-side signups |
| History backfill | Authed batches trust your ts, so you can replay months of an existing log in one pass — from a file, ah track --file does the batching for you |
| Crawler / non-JS traffic | The request log — bots that never execute the tracker still hit your server |
| Query the data from a service | The read API — a read token and any HTTP client, no database string |
| Events from a user's device | Not this page — use the web, React Native, or Unity SDK. A write token must never ship inside a client |
1. Mint a write token
Server ingest is gated on scope. Mint the token on the Tokens page — write tokens are organization-admin only, cover every project in the organization, and are shown exactly once (only a SHA-256 hash is stored, so a lost token gets revoked and replaced, never recovered). Revoking is instant.
Two credentials, two trust levels
The project key (ah_xxxxxxxx) is public — it ships in every tracked page’s source. The token (ah_tok_…) is a secret that can write into your analytics: server environment only, never in a client bundle, an app binary, or a repo.
Put it in your backend’s environment, next to your other secrets:
AGENTHOG_INGEST_TOKEN=ah_tok_… AGENTHOG_PROJECT=ah_xxxxxxxx
2. Send an event
One POST, JSON body, Authorization: Bearer. Treat any 2xx as success — normally 204 with no body; a batch carrying context.install gets 200 with an { attribution } body.
curl -s http://agenthog.io/ingest \
-H "Authorization: Bearer $AGENTHOG_INGEST_TOKEN" \
-H 'content-type: application/json' \
-d '{"project":"ah_xxxxxxxx",
"anonId":"server:42",
"sessionId":"'"$(uuidgen)"'",
"identify":{"email":"[email protected]","traits":{"user_id":"42"}},
"events":[{"ts":1754870400000,"type":"custom","name":"subscription_started",
"props":{"tier":"plus","price_usd":9.99,"store":"app_store"}}]}'From a terminal — or an agent that just finished a deploy — ah track is the same call with the conventions below applied for you (write scope checked first, server:<person> anon id and identify, typed props, --at resolved to an explicit instant and echoed back). --file imports a CSV or NDJSON of history in batches under the caps in responses; --dry-run shows what would be sent. See the CLI page.
ah track subscription_started --at 2025-08-11 --person 42 --email [email protected] \ --prop tier=plus --prop price_usd=9.99 --prop store=app_store
The same thing from a Node/Bun backend, shaped the way it should live in a real service:
// analytics.ts — fire-and-forget. Analytics must never take a request down.
const PROJECT = process.env.AGENTHOG_PROJECT!;
const TOKEN = process.env.AGENTHOG_INGEST_TOKEN!;
export async function track(
userId: string,
name: string,
props?: Record<string, unknown>,
ts: number = Date.now(),
) {
try {
const res = await fetch("http://agenthog.io/ingest", {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
body: JSON.stringify({
project: PROJECT,
anonId: `server:${userId}`, // stable per person
sessionId: crypto.randomUUID(), // a fresh one per delivery is fine
identify: { traits: { user_id: userId } },
events: [{ ts, type: "custom", name, props }],
}),
});
// 401/403 means the token is wrong — log it loudly, it will not self-heal.
if (!res.ok) console.warn("[agenthog]", res.status, await res.text());
} catch (err) {
console.warn("[agenthog] ingest failed", err);
}
}What the token changes
An authed batch is a different path through ingest than an anonymous one — not just the same request with a header on it:
| Behaviour | With a write token |
|---|---|
| Bot scoring | Skipped entirely. The session is classified server with bot_score 0 — a webhook relay has no real client IP or UA and would otherwise score as datacenter traffic |
| Enrichment | No UA parse, no IP hash, no ASN/geo lookup — so no browser, os, device_type, or geo_country on these sessions |
| Timestamps | Trusted verbatim. Anonymous batches clamp ts to ±10 minutes of now; authed ones do not, which is what makes backfill and late webhook retries land on the right day |
| Domain allowlist | Not enforced. The per-project domains check exists to stop a browser that scraped your public key; a trusted server path does not need it |
| Stickiness | server survives the sweep and never gets rescored, so a session cannot drift into suspected_bot later |
| Bad credentials | A present-but-invalid token is a hard 401 — it never falls through to the anonymous bot-scored path. A misconfigured emitter fails loudly instead of quietly polluting data |
Wire format
Same contract as every SDK speaks. Three fields are required at the batch level; everything else is optional:
| Field | Notes |
|---|---|
project | Required. The project key, ah_xxxxxxxx — must be in the token’s scope |
anonId | Required. The person key. Convention for server emitters: server:<user_id> |
sessionId | Required. A uuid. Server events have no real session, so one fresh id per delivery is the convention — do not try to reuse a device session |
events[] | Up to 500 per batch. Each needs type and name; ts is epoch ms, props is free-form JSON. path, url, referrer are optional |
identify | { email?, traits? }. Traits shallow-merge into the existing identity, new keys win |
context | Accepted, but mostly a browser concern. landingUrl is still parsed into entry path and utm_* if you have attribution to attach, and context.attribution (an MMP verdict — { provider, utm_*, params }) applies with the same precedence as from a device SDK; the device fields are ignored on this path |
Use type: "custom" for anything you are naming yourself — the other types (pageview, click, scroll, form, input, leave, identify) carry autocapture semantics the dashboard groups on. Names are stored verbatim and capped at 500 characters, so keep varying values in props, never in the name — the naming rules are identical to the SDKs’. Body limit is 512 KB.
Linking server events to a person
A person who uses your app and also shows up in your billing webhooks accrues two identity rows: the device’s anonymous id, and the server:<user_id> one. They are stitched at query time on coalesce(email, traits->>'user_id', anon_id) — so send the same key from both sides and the timelines merge.
# resolves on any of the three keys, and merges both timelines ah user [email protected] ah user 42 # the user_id trait — the only handle you have for phone-auth users ah user 9f3c1a08… # the raw anon id
This is why identify is worth sending on every server batch even when the event itself is anonymous-looking: an email-less user is reachable only by user_id, and a batch that sends neither is an island.
Where server events show up
| Surface | Server sessions |
|---|---|
ah events | Visible by default — server is folded into the default filter for event-centric verbs |
ah traffic | Excluded, even under --all. A webhook is not a visit; counting it as one would inflate every session number you have |
ah user | Merged into the person’s timeline alongside their device sessions |
| The /mobile dashboard | Trial, churn, and revenue widgets read only server sessions — see below |
ah sql | Everything, unfiltered: WHERE s.classification = 'server' |
Subscription events and the mobile dashboard
The /mobile page’s trial-conversion, churn, and revenue widgets are built on server-ingested lifecycle events — the store is the only thing that knows a renewal happened, so it can only arrive this way. These names are a contract; the widgets group on them exactly:
| Event | When to send it |
|---|---|
trial_started | An intro offer / free trial began |
trial_converted | The trial billed for the first time |
subscription_started | A paid subscription began without a trial |
subscription_renewed | A renewal billed |
subscription_cancelled | Auto-renew turned off — still entitled until period end |
subscription_expired | Entitlement actually ended |
subscription_billing_issue | Payment failed / in grace period |
Props on each: tier, period, product_id, price_usd, store, environment. Two of them are load-bearing — price_usd is what revenue sums (send a number, not "$9.99"), and environment must be PRODUCTION for a row to count. Sandbox traffic should send SANDBOX so it stays out of the numbers; an absent environment counts as production, so backfills without it are not silently dropped.
purchase_completed and paywall_shown are the client-side half of the same story and come from the app SDK, not from here.
The request log
A separate, unauthenticated endpoint for a different problem: crawlers and scrapers that hit your server and never run JavaScript, so the tracker never sees them. It takes the project key only — the rows are request metadata, not events, and land in requests rather than events.
curl -s http://agenthog.io/ingest/requests \
-H 'content-type: application/json' \
-d '{"project":"ah_xxxxxxxx","requests":[
{"ts":1754870400000,"host":"example.com","path":"/pricing",
"ua":"Mozilla/5.0 (compatible; GPTBot/1.0)","ip":"203.0.113.9","status":200}]}'Up to 1000 rows per batch. The server runs bot detection on the UA and an ASN lookup on the IP, then stores only a daily-rotating salted hash of the address. Read it back with ah crawlers. AgentHog ships a zero-dependency middleware for fetch-style servers (createRequestLogger in @agenthog/server) that batches and fire-and-forgets for you — it lives in the AgentHog repo rather than on npm today, so posting the endpoint directly is the supported path.
Attribution postbacks (Singular)
If a game uses Singular for install attribution, Singular’s Internal BI postbacks can deliver the full verdict (network, campaign, creative, adset, re-engagements) straight to AgentHog, server to server. Generate the endpoint URL in project settings (“Install attribution — Singular postbacks”) and paste it into Singular as the app’s Internal BI postback endpoint; Singular POSTs its standard JSON payload to it directly. In the app, call SingularSDK.SetCustomUserId(…) with an id AgentHog knows: your own user_id (as long as it also reaches AgentHog via identify) or AgentHog.AnonId. The payload’s user_id field is how the postback finds the person.
POST http://agenthog.io/postbacks/singular?project=ah_xxxxxxxx&ah_secret=… ← generated in settings
| Postback field | Where it lands |
|---|---|
user_id | The SetCustomUserId value. Required; resolves the person like ah user <ref> (anon id, user_id trait, or email). Missing → dropped |
network | Session utm_source, alias-normalized (Facebook → meta) |
tracker_campaign_name / campaign | Session utm_campaign |
tracker_creative_name / creative | Session utm_content (the “Top ads” view reads it) |
fb_adset_name, tracker_campaign_id, everything else | Stored verbatim in the session’s client_attribution.params |
is_reengagement | Install postbacks stamp the person’s earliest session; re-engagement ones the session covering the event timestamp, else the latest |
A deep-link value the app was actually opened with is never overwritten; the referrer-derived stamp is (the MMP verdict wins); an organic answer writes nothing. A postback that arrives before the app’s first flush is parked and applied automatically once the session appears (for up to 7 days), so delivery order doesn’t matter. Responses: 401 bad secret; everything accepted answers 200 (the code Singular counts as delivered) with a { status: "applied" | "parked" | "dropped" } body; content problems are never a 4xx.
Two ways to hook it up. Pointing the Internal-BI URL straight at AgentHog is the standard MMP-webhook topology: Mixpanel’s attribution docs have the callback going to “your server or a service of your choosing”, and PostHog and Amplitude receive MMP postbacks at their own ingestion endpoints. The alternative is a relay: point Internal BI at your own backend and forward a copy of each postback to the URL above. That is the necessary shape when that URL already feeds your own warehouse, and the conservative one when Meta AMM user-level data is in play.
One caveat, one commitment. What Singular delivers for self-attributing networks (Meta, Google) is governed by your own agreements with those networks, not by where the postback points: Meta user-level data flows only once you have accepted Meta’s Advanced Mobile Measurement (AMM) terms, and AgentHog’s built-in Meta install-referrer decrypt is unaffected either way. And data Singular delivers under your MMP contract is for your own use: AgentHog processes it solely as your service provider and never redistributes it. Whether your network agreements permit delivery to a processor endpoint is yours to confirm; when in doubt, relay.
Reading data back
The mirror image: every CLI verb has a token-scoped HTTP twin under /api/v1, returning the same JSON as --json. A read token is enough, and any member can mint one. The token — not the caller — pins which projects are visible.
curl -s "http://agenthog.io/api/v1/digest?since=7d&project=ah_xxxxxxxx" \
-H "Authorization: Bearer $AGENTHOG_READ_TOKEN"
# or drop to SQL — read-only, scoped server-side to the token's projects
curl -s http://agenthog.io/api/v1/sql \
-H "Authorization: Bearer $AGENTHOG_READ_TOKEN" -H 'content-type: application/json' \
-d '{"query":"SELECT name, count(*) FROM events GROUP BY 1 ORDER BY 2 DESC","limit":50}'
# the queryable tables, columns and helper functions (the SQL contract)
curl -s http://agenthog.io/api/v1/schema -H "Authorization: Bearer $AGENTHOG_READ_TOKEN"Available: traffic, digest, active, retention, campaigns, referrers, crawlers, events, events/top, clicks, paths, sessions, sessions/:ref, users, users/:ref, funnel, funnels, goals, changes, projects, test-ips, and sql. Most take project, since, filter=all|bots, and limit. If you just want a terminal or an agent to read this, the ah CLI already speaks all of it.
Responses
| Status | Meaning |
|---|---|
204 | Accepted. No body |
200 | Accepted; the batch carried context.install and the body is { attribution }. Treat any 2xx as success |
400 | Malformed JSON, missing project/anonId/sessionId, unknown project key, over 500 events, or a body over 512 KB. The body is a one-line reason |
401 | Token missing from a request that sent an Authorization header, invalid, or revoked |
403 | Valid token, but read scope — or the project is not in the token’s scope |
Bad input never returns a 500 or a stack trace; it is always a terse 4xx with the reason in the body.
Gotchas worth knowing before you ship
Retries duplicate
There is no idempotency key. A webhook provider that retries on a timeout will produce a second event, and since timestamps are trusted it will look identical to the first. Ack the webhook before relaying, or carry the provider’s own event id in props so you can dedupe at query time.
Forgetting the header is silent
Drop the Authorization header and the batch still succeeds — down the anonymous path. It gets bot-scored (your server’s datacenter IP scores badly), checked against the domain allowlist, and has its timestamps clamped to ±10 minutes, so a history backfill collapses onto today. If server events look like suspected_bot, this is why.
Props are stored verbatim
Backends have access to far more sensitive data than a browser does. Send ids, tiers, and amounts — not access tokens, card details, or whole user records. Anyone with dashboard access can read props.
Backfill once, then verify
Replaying a log is a one-shot operation with no undo. Send a single batch first, check it with ah events --name <name>, and only then run the full replay. From a file, ah track --file … --dry-run prints the row count, date range and a sample batch before anything is written.