RevenueHogdocs← Back to app

SDK reference

The wire-level API behind the optional SDKs. Five write endpoints, no API key: devices prove which app they are with App Attest.

What the SDK API is

RevenueHog needs no SDK: revenue, the live feed, metrics and alerts all work server-side from your App Store Connect key. The SDK API adds two capabilities. First, user-level attribution and signposts, linking purchases to your own user IDs so anonymous transaction-derived customers upgrade to real profiles: identify (who the user is), attribute (which transaction belongs to them) and events (signpost milestones like onboarding steps). Second, server-driven paywall SKUs: paywall answers which products your paywall should offer for an entitlement, so the offer set is editable (and A/B testable) from the dashboard without an app update. The SDKs page shows the Swift / React Native / Kotlin call sites.

Authentication

There is no API key. On iOS the SDK enrolls each install with App Attest: Apple signs a statement that the caller is your genuine app on a real device, RevenueHog verifies it against the App Store Connect data it already has, and mints an opaque device token (dt_…) the SDK sends as Authorization: Bearer dt_… from then on. Nothing to copy from a dashboard, nothing secret in your binary. Enrolled devices are listed (and revocable) under Settings → SDK devices.

enrollment (the SDK does this for you)
POST /api/sdk/v1/attest/challenge
→ 200 { "challenge": "<base64url, single-use, 5 min TTL>" }

POST /api/sdk/v1/attest
Content-Type: application/json

{
  "keyId": "<App Attest key id, base64>",
  "attestation": "<attestation object, base64>",
  "challenge": "<echoed back>",
  "bundleId": "com.example.app"
}

→ 200 { "deviceToken": "dt_…" }
→ 401 attestation or challenge failed
→ 404 bundle id not connected to any RevenueHog org

Calls without a device token (the simulator, app extensions, React Native and Android today, raw HTTP integrations) are still accepted when the bundleId resolves to exactly one connected app, but the data is stored unverified and labeled that way in the dashboard. A verified jws on attribute upgrades the transaction link even without a token (see below).

POST /api/sdk/v1/identify

Upserts an app user. Repeat calls merge: attributes shallow-merge into what's stored, device fields update, and the user's last-seen timestamp bumps. Anonymous pre-login IDs (the SDKs send $anon_<uuid>) are ordinary user IDs here.

identify
POST /api/sdk/v1/identify
Authorization: Bearer dt_…        // omit when unattested (see Auth)
Content-Type: application/json

{
  "appUserId": "user_42",          // required: your user id
  "bundleId": "com.example.app",   // required
  "platform": "ios",               // optional: "ios" | "android"
  "osVersion": "26.2",             // optional
  "deviceModel": "iPhone17,2",     // optional
  "locale": "en_US",               // optional
  "attributes": { "plan": "pro" }  // optional, flat JSON ≤ 8 KB
}

→ 200 { "ok": true, "id": "…" }

attributes must be a flat JSON object of at most 8 KB. platform accepts exactly ios or android; anything else is ignored rather than rejected.

POST /api/sdk/v1/attribute

Links a store transaction to an app user. One transaction belongs to one user per organization, and re-POSTing the same originalTransactionId with a different appUserId moves it. That upsert is the whole aliasing mechanism: after login, the SDKs re-send earlier anonymous transactions under the real user ID and history re-points automatically.

attribute
POST /api/sdk/v1/attribute
Authorization: Bearer dt_…        // omit when unattested (see Auth)
Content-Type: application/json

{
  "appUserId": "user_42",              // required
  "bundleId": "com.example.app",       // required
  "originalTransactionId": "2000000123456789", // required
  "productId": "com.example.app.pro.monthly",  // optional
  "jws": "<StoreKit 2 signed transaction>"     // optional, recommended on iOS
}

→ 200 { "ok": true }

On iOS, send the StoreKit 2 signed transaction as jws. RevenueHog verifies Apple's signature chain and, when it checks out, trusts the verified fields over the raw ones and marks the link verified, even from an unattested caller. An unverifiable JWS (Xcode StoreKit testing signs locally) is simply ignored. Android clients send the Google Play purchase token as originalTransactionId; it's stored as-is (RevenueHog ingests Apple revenue today; the mapping is kept for future Play support).

POST /api/sdk/v1/events

Batch-ingests signpost events: low-volume funnel milestones around revenue (paywall viewed, onboarding steps), not general analytics. Each app gets at most 32 distinct event names; batches cap at 50 events, properties at 2 KB of flat JSON, and client timestamps clamp to the last 7 days. Per-event problems never fail the batch: invalid names and over-budget names are dropped and reported in the 200 body.

events
POST /api/sdk/v1/events
Authorization: Bearer dt_…        // omit when unattested (see Auth)
Content-Type: application/json

{
  "bundleId": "com.example.app",   // required (here or on each event)
  "events": [                      // required, max 50 per batch
    {
      "name": "paywall_viewed",    // snake_case, ≤ 48 chars, no rh_ prefix
      "appUserId": "user_42",      // required: your user id
      "at": 1755798000000,         // client timestamp, ms or ISO 8601
      "properties": { "placement": "onboarding" }  // optional, flat JSON ≤ 2 KB
    },
    {
      "name": "onboarding_step",   // reserved: powers the onboarding funnel
      "appUserId": "user_42",
      "at": 1755798001000,
      "properties": { "step": "choose_plan", "index": 3, "value": "annual" }
    }
  ]
}

→ 200 { "ok": true, "stored": 2 }
→ 200 { "ok": true, "stored": 1, "rejectedNames": ["over_budget_name"] }

The reserved onboarding_step name powers the funnel's onboarding stage. Send it when a step is shown, with step (snake_case name), index (0-99 display order) and an optional value for the choice the user made ("annual", "fitness", …). Steps appear in the funnel in declared index order, and for attributed users the value feeds conversion-by-answer segmentation.

POST /api/sdk/v1/paywall

Answers which SKUs the paywall for an entitlement (a string your app defines, like pro) should offer, as an ordered list. Not a paywall builder: your app keeps its UI, loads the returned product IDs through StoreKit (which stays the authority on localized prices), and renders them in the order given. Whether to show a paywall at all stays your app's decision via Transaction.currentEntitlements; RevenueHog only decides what to offer. Behind the answer sits a dashboard-controlled config, and optionally an A/B experiment with weighted variants; while one is running the response also carries experimentId and variantKey.

paywall
POST /api/sdk/v1/paywall
Authorization: Bearer dt_…        // omit when unattested (see Auth)
Content-Type: application/json

{
  "bundleId": "com.example.app",     // required
  "installId": "1B7A2C…",            // required: stable per-install UUID
  "appUserId": "$anon_9f2c…",        // required: the CURRENT user id
  "entitlement": "pro",              // required: which paywall
  "environment": "Production"        // optional: AppTransaction.environment
}

→ 200 {
  "entitlement": "pro",
  "skus": [
    { "productId": "com.example.pro.annual",  "kind": "subscription" },
    { "productId": "com.example.pro.monthly", "kind": "subscription" },
    { "productId": "com.example.credits.500", "kind": "iap" }
  ],
  "experimentId": "…",   // present only while an experiment is running
  "variantKey": "b",     // present only while an experiment is running
  "ttlSeconds": 3600     // cache lifetime; 900 while an experiment exists
}

Unknown entitlement, or unresolvable bundle id
→ 200 { "skus": [] }     // render your compiled-in fallback list

The paywall is the money path, so the contract is safe-to-fail: compile a fallback SKU list into the app and render it whenever the response is empty, errors, or doesn't arrive in time. Cache the answer per entitlement for ttlSeconds, serve stale on network failure, and refetch when the user logs in or out (the appUserId you send is how purchases join experiment results). installId is a UUID your integration mints once per install and persists in the strongest storage available (iOS: a non-synchronizable Keychain item); it exists only so experiment assignment sticks to the device. environment is StoreKit's AppTransaction.environment, which lets TestFlight traffic be seen for what it is in results. To preview a specific variant during QA, send forceVariant from a non-Production environment: the named variant is returned marked forced and nothing is recorded.

When an experiment is running, fire the impression beacon when the paywall actually appears, after StoreKit product loading. Fetching assigns; impressions expose: results prefer display-time exposure counts, so apps that prefetch config don't inflate their denominators. The beacon also carries the product IDs that actually rendered, so a variant whose SKU fails to load in some storefront shows up as a broken test instead of a losing offer.

impression
POST /api/sdk/v1/paywall/impression
Authorization: Bearer dt_…        // omit when unattested (see Auth)
Content-Type: application/json

{
  "bundleId": "com.example.app",
  "installId": "1B7A2C…",
  "experimentId": "…",               // from the paywall response
  "renderedSkus": ["com.example.pro.annual", "com.example.pro.monthly"]
}

→ 200 { "ok": true }

Rate limits, CORS & errors

Attested writes are limited to 240 requests per minute per organization; unattested writes to 30 per minute per device. Over a limit, responses are 429 with a retry-after header (seconds). Other errors: 400 for malformed JSON or missing required fields, 401 for an invalid device token (the SDKs re-enroll once, then fall back to unattested). Both endpoints are idempotent in practice: the official SDKs retry with exponential backoff and queue offline, and re-sends merge rather than duplicate.

CORS is deliberately permissive (* origin, POST + OPTIONS): there is no secret to protect, and unresolvable writes are dropped, not stored. Responses are never cached.

Versioning

The API is versioned in the path (/v1/). Fields may be added over time; existing fields won't change meaning within v1.