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.
https://api.measura.dev/v1/ingest # SDK default ingestion endpoint
{functions-host}/functions/v1/{function} # direct function invocationIf 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.
GET /functions/v1/resolve-key
X-Measura-Api-Key: msr_4f3c...e91aWe 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
signaturefield itself. - Compute HMAC-SHA256 over that string with the signing secret, and hex encode the result. It must be exactly 64 characters.
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
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.
GET /functions/v1/analytics?resource=summary
Authorization: Bearer <session token>Event ingestion
/functions/v1/ingestSubmits 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.
{
"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
| Limit | Value | Response when exceeded |
|---|---|---|
| Events per request | 50 | 400 |
| Request body size | 256 KB | 413 |
| Signature age | plus or minus 10 minutes | Per event AUTH_ERROR |
| Monthly events | Per plan allowance | Per event QUOTA_EXCEEDED |
Event fields
Required
event_typeenumclick, install, open, session_start, session_end, purchase, re_engagement, uninstall, custom.timestampintegercustomer_iduuidapp_iduuidevent_idstring, 8 to 128 charssdk_versionstringsignaturestring, exactly 64 hex charsOptional
sent_atintegertimestamp for the replay check, which lets you send historical events without tripping it.gaid, android_idstringdevice_fingerprint_componentsobjectuser_agent, device_model, os_version and optional screen_resolution. The server adds the masked IP address before hashing.click_iduuidcampaign_iduuidchannelenumwhatsapp, ussd, qr_code, influencer, meta, google, tiktok, twitter, sms, email, organic, push_notification, referral, unknown.referrerstringdevice_model, os_name, os_version, app_version, screen_resolutionstringevent_propertiesobjectrevenuenumbercurrencystringNGN or USD.Response
Ingestion always returns HTTP 200
results array rather than relying on the status code.{
"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
| Status | Cause |
|---|---|
400 | Malformed JSON, empty array, or more than 50 events. |
405 | Method other than POST or OPTIONS. |
413 | Request body larger than 256 KB. |
Health check
/functions/v1/ingest/healthLiveness probe for the ingestion service.
AuthNone.
{ "status": "ok", "service": "ingest" }This endpoint backs our public status page. It is safe to poll, but please keep the interval reasonable.
Key resolution
/functions/v1/resolve-keyExchanges 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.
{
"customer_id": "3f1c8a2e-9b47-4d1e-8a6f-2c5b9e7d1a03",
"app_id": "7d2e5b1a-4c93-4f8e-b6a1-9e3c7f2d5b84",
"sdk_secret": "b91f7c...4e2a"
}Protect the signing secret
| Status | Meaning |
|---|---|
401 | Missing, malformed, invalid or revoked key, or the account is inactive. |
405 | Method other than GET or OPTIONS. |
429 | More than 30 requests per minute from one IP address. |
Deep links
/functions/v1/deep-link/{slug}Resolves a short link, records the click, and redirects to the correct destination for the device.
AuthNone. This is a public redirector.
Short links are normally served from a branded domain, so the URL your users see is link.measura.dev/{slug} rather than the function path.
Behaviour
- Android devices are redirected to the configured app destination. Everything else goes to your fallback URL, since there is no app for them to install.
- Appends
measura_click_idandmeasura_deferredto the destination so the SDK can attribute the resulting install. - For Google Play destinations, the identifier travels in the
referrerparameter, which survives the install and gives deterministic attribution. - An unknown or inactive slug redirects to the configured default rather than showing an error.
Blocked destination schemes
javascript:, data:, vbscript:, file:, blob: and plain http: destinations.| Status | Meaning |
|---|---|
302 | Redirect to the resolved destination, or to the default fallback. |
404 | Slug shorter than three characters. |
429 | More than 120 requests per minute from one /24 network. |
Analytics
/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
resourcerequiredapp_idoptionaldate_fromoptionaldate_tooptionalResources
| Resource | Returns |
|---|---|
health | Liveness probe. Answered before authentication. |
summary | Totals for the period: installs, average confidence score, high confidence rate, and breakdowns by attribution model and channel. |
installs | Daily install counts by attribution model and channel, with average confidence. |
cohorts | Retention by install cohort and day offset. |
fraud | Fraud flags with the rule that fired, its version, the triggering signal values and the score. Limited to 500 rows. |
attribution_log | The glass box log: confidence breakdown, signals used, written reason, rejected candidates and postback status. Limited to 200 rows. |
revenue | Payment totals for the period, broken down per currency and per provider, with the share of payments that matched a known device. |
payments | Individual 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.
{
"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.
{
"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
}
}| Status | Meaning |
|---|---|
400 | Unknown resource. |
401 | Missing or invalid session. |
403 | No account linked to the signed in user. |
405 | Method 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.
/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.
| Provider | Header | Expected value |
|---|---|---|
| paystack | x-paystack-signature | HMAC-SHA512 of the raw body, hex, keyed with your Paystack secret key |
| flutterwave | verif-hash | The shared secret verbatim, as configured in the Flutterwave dashboard |
| generic | x-measura-signature | HMAC-SHA256 of the raw body, hex, keyed with the secret Measura issued |
Sign the bytes, not the object
Responses
{
"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.
| Status | Meaning |
|---|---|
200 recorded | Payment stored and attribution attempted. |
200 duplicate | This provider reference was already recorded. Providers retry, so redelivery is expected and safe. |
200 ignored | A valid event that is not a completed payment, for example a charge that failed. |
400 | Missing or malformed app_id, unknown provider, or a body that is not JSON. |
401 | Signature did not verify. |
404 | No active webhook for that app and provider. |
413 | Body exceeded the size limit. |
Why failures return 200 sometimes and 4xx others
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.
/functions/v1/generate-keyIssues an SDK key for one of your applications.
AuthBearer, a signed-in user session. Not an SDK key.
{
"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.
| Status | Meaning |
|---|---|
401 | Missing, malformed or expired session. |
403 | The session has no linked account, or the app belongs to another one. |
400 | Missing 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.
/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
customer_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.
| Procedure | Effect |
|---|---|
upsert_sdk_config | Overrides 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_config | Removes every override, returning the account to platform defaults. |
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
| Procedure | Effect |
|---|---|
list_payment_webhooks | Your webhooks, with the app, provider, label and last use. The signing secret is never returned. |
create_payment_webhook | Creates 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_webhook | Deactivates 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
| Procedure | Effect |
|---|---|
team_invite_member | Adds 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_member | Removes 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.
/rest/v1/rpc/export_my_account_dataReturns your apps, campaigns, links, installs, attributions, events, fraud flags and payments.
AuthBearer, a signed-in user session, plus the apikey header.
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.jsonp_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 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.
/functions/v1/migration-importer/startCreates an import job and returns the column mapping that will be applied.
AuthBearer session token.
// 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" }
}/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.
/functions/v1/migration-importer/status?job_id={id}Reports progress and any row level errors.
AuthBearer session token.
{
"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
}| Status | Meaning |
|---|---|
400 | Missing fields, unsupported platform, or job not in a pending state. |
403 | The application does not belong to your account. |
404 | Job not found. |
413 | File over 5 MB or over 50,000 rows. |
415 | Unsupported content type. |
Error codes
Per event errors inside an ingestion response carry a machine readable code.
| Code | Meaning | What to do |
|---|---|---|
VALIDATION_ERROR | A field is missing, malformed or out of range. | Fix the payload. Retrying unchanged will fail again. |
AUTH_ERROR | Bad signature, unknown account, inactive account, or outside the replay window. | Verify the canonical payload rules and check for clock drift. |
RATE_LIMIT | Too many requests. | Back off exponentially and retry. |
QUOTA_EXCEEDED | The monthly event allowance is exhausted. | Upgrade the plan or wait for the monthly reset. Retrying will not help. |
INTERNAL_ERROR | Something 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.
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)UnitIllegalArgumentException on a blank key.trackEvent(eventType, properties)UnitString or an EventType. Throws IllegalStateException if the SDK is not initialised.identify(userId, traits)UnitdisableTracking() / enableTracking()Unitflush()UnitisInitialised()BooleanConfiguration
| Option | Default | Effect |
|---|---|---|
| batchSize | 50 | Events buffered before a send is triggered. |
| flushIntervalMs | 30000 | Timer driven flush interval. |
| wifiOnlyMode | true | Hold events until Wi-Fi is available. See the note below. |
| suppressOnLowBattery | true | Defer sending on low battery. |
| lowBatteryThreshold | 15 | Battery percentage below which sending is deferred. |
| maxRetryAttempts | 5 | Retries before the batch is written back to disk. |
| maxOfflineQueueSize | 10000 | Persistent queue depth. Oldest events drop first. |
| ingestEndpoint | api.measura.dev/v1/ingest | Override for self hosted or staging. |
| logLevel | NONE | NONE, 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.
wifiOnlyModeis 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
| Behaviour | Value |
|---|---|
| Persistent store | SQLite database on the device |
| Queue depth | 10,000 events |
| Overflow policy | Oldest dropped first |
| Batch size | 50 events |
| Flush interval | 30 seconds |
| Retry attempts | 5 |
| Backoff | Doubling from 1 second |
| Compression | gzip |
When retries are exhausted the batch returns to persistent storage and is retried in a later session rather than discarded.