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 needDo this
Billing / subscription eventsRelay the store or provider webhook (RevenueCat, Stripe) to POST /ingest with a write token — see subscription events
Events with no client presentCron jobs, queue workers, refunds, moderation decisions, server-side signups
History backfillAuthed 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 trafficThe request log — bots that never execute the tracker still hit your server
Query the data from a serviceThe read API — a read token and any HTTP client, no database string
Events from a user's deviceNot 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:

BehaviourWith a write token
Bot scoringSkipped 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
EnrichmentNo UA parse, no IP hash, no ASN/geo lookup — so no browser, os, device_type, or geo_country on these sessions
TimestampsTrusted 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 allowlistNot enforced. The per-project domains check exists to stop a browser that scraped your public key; a trusted server path does not need it
Stickinessserver survives the sweep and never gets rescored, so a session cannot drift into suspected_bot later
Bad credentialsA 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:

FieldNotes
projectRequired. The project key, ah_xxxxxxxx — must be in the token’s scope
anonIdRequired. The person key. Convention for server emitters: server:<user_id>
sessionIdRequired. 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
contextAccepted, 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

SurfaceServer sessions
ah eventsVisible by default — server is folded into the default filter for event-centric verbs
ah trafficExcluded, even under --all. A webhook is not a visit; counting it as one would inflate every session number you have
ah userMerged into the person’s timeline alongside their device sessions
The /mobile dashboardTrial, churn, and revenue widgets read only server sessions — see below
ah sqlEverything, 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:

EventWhen to send it
trial_startedAn intro offer / free trial began
trial_convertedThe trial billed for the first time
subscription_startedA paid subscription began without a trial
subscription_renewedA renewal billed
subscription_cancelledAuto-renew turned off — still entitled until period end
subscription_expiredEntitlement actually ended
subscription_billing_issuePayment 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 fieldWhere it lands
user_idThe SetCustomUserId value. Required; resolves the person like ah user <ref> (anon id, user_id trait, or email). Missing → dropped
networkSession utm_source, alias-normalized (Facebookmeta)
tracker_campaign_name / campaignSession utm_campaign
tracker_creative_name / creativeSession utm_content (the “Top ads” view reads it)
fb_adset_name, tracker_campaign_id, everything elseStored verbatim in the session’s client_attribution.params
is_reengagementInstall 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

StatusMeaning
204Accepted. No body
200Accepted; the batch carried context.install and the body is { attribution }. Treat any 2xx as success
400Malformed JSON, missing project/anonId/sessionId, unknown project key, over 500 events, or a body over 512 KB. The body is a one-line reason
401Token missing from a request that sent an Authorization header, invalid, or revoked
403Valid 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.