Measura

API Reference

Every endpoint, field, limit and error code, plus the public surface of each SDK. Documented against the shipping implementation.

Overview

The Measura API is a small set of HTTPS endpoints. Most integrations never call it directly, since the SDKs handle ingestion, signing and retries for you. Call it directly for server to server tracking, or to build against the analytics data yourself.

Base URL

All endpoints are served from your Measura functions host. The SDKs default to the ingestion path below and derive the other paths from it.

Base URLtext
https://api.measura.dev/v1/ingest        # SDK default ingestion endpoint
{functions-host}/functions/v1/{function}  # direct function invocation

If you are self hosting or pointing at a staging project, override the endpoint through SDK configuration rather than patching the SDK.

Conventions

  • All request and response bodies are JSON, encoded as UTF-8.
  • Timestamps are Unix milliseconds unless a field is documented as an ISO date.
  • Identifiers described as UUIDs are validated as version 4.
  • Ingestion accepts gzipped request bodies, which the SDKs use by default.

Authentication

There are three separate mechanisms, used at different points. Do not mix them up: the API key never authenticates an event, and the signing secret never appears in a header.

1. API key

Issued per application. The format is the prefix msr_ followed by 64 hexadecimal characters.

Key resolution requesthttp
GET /functions/v1/resolve-key
X-Measura-Api-Key: msr_4f3c...e91a

We store only a SHA-256 hash of the key. The plaintext is displayed once when it is created and cannot be recovered afterwards, so a lost key must be revoked and reissued.

The API key is used exactly once per SDK session, to exchange it for the identifiers and the signing secret. It does not authenticate event submission.

2. Event signature

Every event carries an HMAC-SHA256 signature computed with the signing secret returned by key resolution. This is what actually authenticates ingestion.

The canonical payload rules are strict and easy to get wrong:

  • Serialise the event object as JSON with no whitespace.
  • Sort top level keys alphabetically.
  • Exclude the signature field itself.
  • Compute HMAC-SHA256 over that string with the signing secret, and hex encode the result. It must be exactly 64 characters.
Signing an eventjavascript
import { createHmac } from 'node:crypto'

function sign(event, sdkSecret) {
  const { signature: _omit, ...rest } = event

  // Top level keys sorted, no whitespace.
  const canonical = JSON.stringify(
    Object.keys(rest).sort().reduce((acc, k) => {
      acc[k] = rest[k]
      return acc
    }, {})
  )

  return createHmac('sha256', sdkSecret).update(canonical).digest('hex')
}

Replay window

Requests are rejected when the signed sent_at value (or timestamp, if sent_at is absent) is more than ten minutes away from server time in either direction. Clock drift on the sending machine is the most common cause of an otherwise valid signature being refused.

3. Dashboard session token

The analytics and migration endpoints authenticate with a session token issued at sign-in, sent as a bearer token. The account identity is read from server controlled token claims, so a client cannot request another tenant’s data by changing a parameter.

Authenticated dashboard requesthttp
GET /functions/v1/analytics?resource=summary
Authorization: Bearer <session token>

Event ingestion

POST/functions/v1/ingest

Submits one event or a batch of events for processing.

AuthPer event HMAC signature. No bearer token.

Request

Send either a single event object, or an object with an events array. Cross origin requests are permitted from any origin.

POST /functions/v1/ingestjson
{
  "events": [
    {
      "event_type": "install",
      "timestamp": 1754899200000,
      "sent_at": 1754899201500,
      "customer_id": "3f1c8a2e-9b47-4d1e-8a6f-2c5b9e7d1a03",
      "app_id": "7d2e5b1a-4c93-4f8e-b6a1-9e3c7f2d5b84",
      "event_id": "install_1754899200000_a3f9c1e7",
      "sdk_version": "1.0.0",
      "device_model": "Samsung SM-A245F",
      "os_name": "android",
      "os_version": "14",
      "app_version": "2.3.1",
      "device_fingerprint_components": {
        "user_agent": "MeasuraSDK/1.0.0 (Android 14; SM-A245F) App/2.3.1",
        "device_model": "Samsung SM-A245F",
        "os_version": "14",
        "screen_resolution": "1080x2340"
      },
      "referrer": "measura_click_id=8c1f...",
      "signature": "9f2a...c74e"
    }
  ]
}

Limits

LimitValueResponse when exceeded
Events per request50400
Request body size256 KB413
Signature ageplus or minus 10 minutesPer event AUTH_ERROR
Monthly eventsPer plan allowancePer event QUOTA_EXCEEDED

Event fields

Required

event_typeenum
One of click, install, open, session_start, session_end, purchase, re_engagement, uninstall, custom.
timestampinteger
Unix milliseconds. When the event occurred on the device.
customer_iduuid
From key resolution.
app_iduuid
From key resolution.
event_idstring, 8 to 128 chars
Your idempotency key. Resubmitting the same value is deduplicated rather than double counted.
sdk_versionstring
Identifies the sending client.
signaturestring, exactly 64 hex chars
HMAC-SHA256 as described in Authentication.

Optional

sent_atinteger
Unix milliseconds at signing time. Preferred over timestamp for the replay check, which lets you send historical events without tripping it.
gaid, android_idstring
Device identifiers. An all zero UUID, which signals limited ad tracking, is discarded after signature verification.
device_fingerprint_componentsobject
user_agent, device_model, os_version and optional screen_resolution. The server adds the masked IP address before hashing.
click_iduuid
The Measura click identifier, when known.
campaign_iduuid
Associates the event with a campaign.
channelenum
One of whatsapp, ussd, qr_code, influencer, meta, google, tiktok, twitter, sms, email, organic, push_notification, referral, unknown.
referrerstring
Install referrer string or source URL.
device_model, os_name, os_version, app_version, screen_resolutionstring
Device and application context.
event_propertiesobject
Free form properties you attach. Not inspected by Measura, so do not place sensitive personal data here.
revenuenumber
Must be zero or greater. Promoted from properties when present.
currencystring
ISO 4217 code, for example NGN or USD.

Response

Ingestion always returns HTTP 200

A successful HTTP response does not mean every event was accepted. Each event carries its own result, and partial failure within a batch is normal. Inspect the results array rather than relying on the status code.
200 OKjson
{
  "results": [
    {
      "status": "accepted",
      "event_id": "install_1754899200000_a3f9c1e7",
      "server_timestamp": "2026-08-11T09:20:01.732Z"
    },
    {
      "status": "deduplicated",
      "event_id": "open_1754899210000_b7d2f4a1",
      "server_timestamp": "2026-08-11T09:20:01.733Z"
    },
    {
      "error": "Invalid signature",
      "code": "AUTH_ERROR"
    }
  ],
  "batch_size": 3
}

Per event status is accepted, deduplicated or rejected.

Top level errors

StatusCause
400Malformed JSON, empty array, or more than 50 events.
405Method other than POST or OPTIONS.
413Request body larger than 256 KB.

Health check

GET/functions/v1/ingest/health

Liveness probe for the ingestion service.

AuthNone.

200 OKjson
{ "status": "ok", "service": "ingest" }

This endpoint backs our public status page. It is safe to poll, but please keep the interval reasonable.

Key resolution

GET/functions/v1/resolve-key

Exchanges an API key for the account identifiers and the event signing secret.

AuthX-Measura-Api-Key header. Minimum 16 characters.

The SDKs call this once at initialisation and cache the result for the session.

200 OKjson
{
  "customer_id": "3f1c8a2e-9b47-4d1e-8a6f-2c5b9e7d1a03",
  "app_id": "7d2e5b1a-4c93-4f8e-b6a1-9e3c7f2d5b84",
  "sdk_secret": "b91f7c...4e2a"
}

Protect the signing secret

The response contains the secret used to sign every event for the account. Anyone holding it can submit events on your behalf. Never log it, never commit it, and never expose this endpoint through a proxy reachable by untrusted clients.
StatusMeaning
401Missing, malformed, invalid or revoked key, or the account is inactive.
405Method other than GET or OPTIONS.
429More than 30 requests per minute from one IP address.

Analytics

GET/functions/v1/analytics?resource={resource}

Reads aggregated attribution, cohort and fraud data for the signed in account.

AuthBearer session token. The account is taken from token claims, never from a parameter.

Parameters

resourcerequired
One of the resources listed below.
app_idoptional
Restricts results to one application.
date_fromoptional
ISO date. Defaults to 30 days ago.
date_tooptional
ISO date. Defaults to today.

Resources

ResourceReturns
healthLiveness probe. Answered before authentication.
summaryTotals for the period: installs, average confidence score, high confidence rate, and breakdowns by attribution model and channel.
installsDaily install counts by attribution model and channel, with average confidence.
cohortsRetention by install cohort and day offset.
fraudFraud flags with the rule that fired, its version, the triggering signal values and the score. Limited to 500 rows.
attribution_logThe glass box log: confidence breakdown, signals used, written reason, rejected candidates and postback status. Limited to 200 rows.
revenuePayment totals for the period, broken down per currency and per provider, with the share of payments that matched a known device.
paymentsIndividual payments: provider, reference, amount, currency, whether it matched a device, and when it arrived. The raw provider payload is never returned.

Revenue is reported per currency, never summed

revenue returns a total for each currency separately rather than one figure. This is deliberate: adding NGN to USD produces a number that looks like revenue and means nothing. If you need a single figure, convert at a rate you control, at the moment you report, rather than relying on one baked in here.

GET ?resource=revenuejson
{
  "period": { "from": "2026-07-01", "to": "2026-07-31" },
  "payment_count": 412,
  "by_currency": {
    "NGN": { "gross": 6120000.00, "count": 388, "attributed": 331 },
    "USD": { "gross": 2140.00, "count": 24, "attributed": 19 }
  },
  "by_provider": { "paystack": 388, "flutterwave": 24 },
  "attribution_rate_pct": 85.0
}

attributed counts payments tied to a known device, and attribution_rate_pct is that share across the period. A rate well below your install match rate usually means payments are arriving with an identifier the SDK never saw, rather than a fault in attribution.

GET ?resource=summaryjson
{
  "period": { "from": "2026-07-12", "to": "2026-08-11" },
  "total_installs": 18432,
  "avg_confidence_score": 81.4,
  "high_confidence_rate_pct": 72.6,
  "installs_by_model": {
    "install_referrer": 8120,
    "click_id": 4306,
    "deterministic": 2988,
    "probabilistic": 1442,
    "organic": 1576
  },
  "installs_by_channel": {
    "whatsapp": 6210,
    "meta": 4980,
    "qr_code": 2110,
    "organic": 1576
  }
}
StatusMeaning
400Unknown resource.
401Missing or invalid session.
403No account linked to the signed in user.
405Method other than GET.

Payment webhooks

Play Billing does not see every payment. Bank transfers, USSD, and card payments taken through a local processor are invisible to it, and for many African apps that is most of the revenue. Point your payment provider at this endpoint and Measura ties each completed payment back to the campaign that produced the install.

POST/functions/v1/payment-webhook?app_id={uuid}&provider={provider}

Receives a completed payment from your provider and attributes it.

AuthThe provider's signature over the raw request body. No JWT.

Both query parameters are required. provider must be one of paystack, flutterwave or generic. The body is your provider's own payload, forwarded unmodified.

Creating a webhook

Create the endpoint in the dashboard under Integrations. Measura returns the URL to paste into your provider, and the signing secret once. It is not retrievable afterwards; revoke and create a new one if it is lost.

Signature verification

Every provider signs differently, so the header and algorithm depend on which one you configured. In all three cases the signature covers the exact bytes of the request body.

ProviderHeaderExpected value
paystackx-paystack-signatureHMAC-SHA512 of the raw body, hex, keyed with your Paystack secret key
flutterwaveverif-hashThe shared secret verbatim, as configured in the Flutterwave dashboard
genericx-measura-signatureHMAC-SHA256 of the raw body, hex, keyed with the secret Measura issued

Sign the bytes, not the object

The signature covers the request body exactly as sent. If you proxy this endpoint, forward the raw body rather than parsing and re-serialising it: reordering a key or changing whitespace produces different bytes and the signature will not verify.

Responses

200 OK, payment recordedjson
{
  "status": "recorded",
  "payment_event_id": "3f0a...",
  "reference": "your-provider-reference",
  "matched": true
}

matched reports whether the payer was tied to a known device. A payment with matched: false is still recorded and still counts toward revenue; it simply has no install to attribute to.

StatusMeaning
200 recordedPayment stored and attribution attempted.
200 duplicateThis provider reference was already recorded. Providers retry, so redelivery is expected and safe.
200 ignoredA valid event that is not a completed payment, for example a charge that failed.
400Missing or malformed app_id, unknown provider, or a body that is not JSON.
401Signature did not verify.
404No active webhook for that app and provider.
413Body exceeded the size limit.

Why failures return 200 sometimes and 4xx others

Providers retry on any non-2xx. duplicate and ignored are answered 200 because retrying them would never produce a different outcome. A 401 or 400 is a body we will never accept, so it is refused outright rather than left to loop.

Amounts and currency

Paystack and Flutterwave send minor units. Measura converts on the way in, so 499900 kobo is stored as 4999.00. Revenue totals are always reported per currency and never summed across them, because adding NGN to USD produces a number that means nothing.

Creating an API key

SDK keys are normally created in the dashboard. This endpoint exists for teams that provision apps programmatically.

POST/functions/v1/generate-key

Issues an SDK key for one of your applications.

AuthBearer, a signed-in user session. Not an SDK key.

Requestjson
{
  "app_id": "0f7c...",
  "label": "Production Android"
}

label is optional and defaults to SDK Key. The app must belong to the account the session is linked to; passing another tenant's app_id returns 403 rather than a working key.

StatusMeaning
401Missing, malformed or expired session.
403The session has no linked account, or the app belongs to another one.
400Missing or malformed app_id.

Account operations

Everything the dashboard does, it does through these. They are ordinary PostgREST procedure calls, so anything that speaks HTTP can drive them: provisioning a new app, flipping the SDK kill switch during an incident, or rotating a payment webhook secret from a deploy script.

POST/rest/v1/rpc/{procedure}

Account scoped operations, called the same way the dashboard calls them.

AuthBearer, a signed-in user session, plus the apikey header.

Your account is taken from the token, never from the request

None of these accept an account identifier. Each reads it from thecustomer_id claim on your session, so there is no parameter that could be pointed at another tenant. If a procedure below looks like it is missing an argument you expected, that is why.

SDK configuration

The SDK asks the server for its settings, so these take effect on the next configuration fetch without an app release. This is the lever to reach for during an incident rather than shipping a hotfix to the store.

ProcedureEffect
upsert_sdk_configOverrides tracking_enabled, sampling_rate, wifi_only_mode, batch_size, flush_interval_ms or suppress_on_low_battery for your account. Any argument left null inherits the platform default rather than resetting it.
clear_sdk_configRemoves every override, returning the account to platform defaults.
Stop collection for your accountbash
curl -X POST "$MEASURA_URL/rest/v1/rpc/upsert_sdk_config" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $SESSION_JWT" \
  -H "Content-Type: application/json" \
  -d '{"p_tracking_enabled": false}'

Payment webhooks

ProcedureEffect
list_payment_webhooksYour webhooks, with the app, provider, label and last use. The signing secret is never returned.
create_payment_webhookCreates one for an app you own and returns the URL and the signing secret. The secret is shown once and cannot be retrieved later. Creating a second webhook for the same app and provider deactivates the first.
revoke_payment_webhookDeactivates a webhook. Deliveries to it stop being accepted.

create_payment_webhook checks the app belongs to you before issuing anything, so a valid session cannot mint a webhook against another account. Paystack and Flutterwave require the secret from their own dashboard; generic generates one for you.

Team

ProcedureEffect
team_invite_memberAdds an existing Measura user to your account with a role of owner, developer, marketer or viewer. The person must already have signed up.
team_remove_memberRemoves a member. The last owner cannot be removed, since that would leave the account with nobody able to invite anyone back.

Both require the caller to be an owner. A session without that role is refused rather than silently doing nothing.

Exporting your account

Everything Measura holds for your account, in one JSON document. There is no notice period, no support ticket, and no export fee. If you decide to leave, your attribution history leaves with you.

POST/rest/v1/rpc/export_my_account_data

Returns your apps, campaigns, links, installs, attributions, events, fraud flags and payments.

AuthBearer, a signed-in user session, plus the apikey header.

Export to a filebash
curl -X POST "$MEASURA_URL/rest/v1/rpc/export_my_account_data" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $SESSION_JWT" \
  -H "Content-Type: application/json" \
  -d '{"p_max_rows": 50000}' > measura-export.json

p_max_rows caps rows per table and defaults to 50,000. It is clamped to 200,000: a single response has to fit in memory, and an uncapped export of a large account would not. For accounts beyond that, export in date ranges or ask us for a bulk dump.

There is no account parameter, deliberately

The account comes from your session, not from the request, so there is no argument that could be pointed at another tenant. The underlying function does take an account id and is service role only; this wrapper is the reason it can be exposed safely.

The document includes a totals block per table, so you can confirm the export is complete before relying on it. If a count there equals your cap, the table was truncated and you should re-export that range with a higher one.

Migration import

Three endpoints, called in order, import historical data from another attribution platform. The Migration Guide covers the column mappings and file preparation in detail.

POST/functions/v1/migration-importer/start

Creates an import job and returns the column mapping that will be applied.

AuthBearer session token.

Request and responsejson
// Request
{ "app_id": "7d2e5b1a-...", "source_platform": "appsflyer" }

// 200 OK
{
  "job_id": "c4a9...",
  "message": "Import job created",
  "column_mapping": { "Install Time": "installed_at", "Advertising ID": "gaid" }
}
POST/functions/v1/migration-importer/upload?job_id={id}

Uploads the data file and begins processing.

AuthBearer session token. The job must belong to your account and be pending.

Accepts application/json, text/csv or text/plain. Maximum 5 MB and 50,000 rows.

GET/functions/v1/migration-importer/status?job_id={id}

Reports progress and any row level errors.

AuthBearer session token.

200 OKjson
{
  "job_id": "c4a9...",
  "status": "processing",
  "total_rows": 24500,
  "processed_rows": 11200,
  "progress_pct": 45.7,
  "errors": [],
  "started_at": "2026-08-11T09:04:22.010Z",
  "completed_at": null
}
StatusMeaning
400Missing fields, unsupported platform, or job not in a pending state.
403The application does not belong to your account.
404Job not found.
413File over 5 MB or over 50,000 rows.
415Unsupported content type.

Error codes

Per event errors inside an ingestion response carry a machine readable code.

CodeMeaningWhat to do
VALIDATION_ERRORA field is missing, malformed or out of range.Fix the payload. Retrying unchanged will fail again.
AUTH_ERRORBad signature, unknown account, inactive account, or outside the replay window.Verify the canonical payload rules and check for clock drift.
RATE_LIMITToo many requests.Back off exponentially and retry.
QUOTA_EXCEEDEDThe monthly event allowance is exhausted.Upgrade the plan or wait for the monthly reset. Retrying will not help.
INTERNAL_ERRORSomething failed on our side.Retry with backoff. If it persists, contact support.

Android SDK

Kotlin, minimum SDK 21. The release archive measures under 64 KB gzipped, enforced by a build gate at the same figure, so the number here cannot drift from the shipped artifact. Integrating it grows a minified APK by about 310 KB, since the archive excludes AndroidX WorkManager and the Room storage behind its job queue. Apps already using Kotlin coroutines see less.

Initialisationkotlin
import dev.measura.sdk.Measura
import dev.measura.sdk.core.MeasuraConfig

Measura.init(
    context = applicationContext,
    apiKey = "msr_4f3c...e91a",
    config = MeasuraConfig(
        batchSize = 50,
        flushIntervalMs = 30_000L,
        wifiOnlyMode = true,
        maxOfflineQueueSize = 10_000
    )
)

Methods

init(context, apiKey, config)Unit
Starts the SDK, resolves the API key, and emits a one time install event plus a session start. Calling it twice is a safe no-op. Throws IllegalArgumentException on a blank key.
trackEvent(eventType, properties)Unit
Queues an event. Accepts a String or an EventType. Throws IllegalStateException if the SDK is not initialised.
identify(userId, traits)Unit
Attaches a user identifier to subsequent events and emits an identify event. See the caveat below.
disableTracking() / enableTracking()Unit
Stops and resumes collection. Held in memory only, so reapply on launch if the user has opted out.
flush()Unit
Requests an immediate send.
isInitialised()Boolean
Whether init has completed.

Configuration

OptionDefaultEffect
batchSize50Events buffered before a send is triggered.
flushIntervalMs30000Timer driven flush interval.
wifiOnlyModetrueHold events until Wi-Fi is available. See the note below.
suppressOnLowBatterytrueDefer sending on low battery.
lowBatteryThreshold15Battery percentage below which sending is deferred.
maxRetryAttempts5Retries before the batch is written back to disk.
maxOfflineQueueSize10000Persistent queue depth. Oldest events drop first.
ingestEndpointapi.measura.dev/v1/ingestOverride for self hosted or staging.
logLevelNONENONE, ERROR, DEBUG or VERBOSE.

Two things that surprise people

Wi-Fi only is the default. On a cellular only device, events queue on disk and are not transmitted until Wi-Fi is available. This is deliberate, for data cost reasons. Set wifiOnlyMode = false if you need prompt delivery.

No advertising identifier is collected. The current Android SDK does not read or transmit the Google Advertising ID. Android attribution relies on the Play Install Referrer, which is deterministic and generally stronger.

There is no handleDeepLink method and no tracking permission call. The Play Install Referrer carries the click identifier through the store install automatically, which covers the attribution path without either.

Offline behaviour and delivery

The SDK is built to survive poor connectivity without losing events. Understanding the deferral rules explains most reports of missing data.

When sending is deferred

In every case below the event is written to persistent storage rather than dropped.

  • No network connectivity.
  • wifiOnlyMode is on and the device is on cellular. This is the default.
  • Battery below the threshold with battery suppression enabled.
  • The API key has not finished resolving.

Storage and limits

BehaviourValue
Persistent storeSQLite database on the device
Queue depth10,000 events
Overflow policyOldest dropped first
Batch size50 events
Flush interval30 seconds
Retry attempts5
BackoffDoubling from 1 second
Compressiongzip

When retries are exhausted the batch returns to persistent storage and is retried in a later session rather than discarded.