Every endpoint, field, limit and error code, plus the public surface of each SDK. Documented against the shipping implementation.
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.
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.
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.
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.
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:
signature field itself.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.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>/functions/v1/ingestSubmits one event or a batch of events for processing.
AuthPer event HMAC signature. No bearer token.
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"
}
]
}| 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_typeenumclick, install, open, session_start, session_end, purchase, re_engagement, uninstall, custom.timestampintegercustomer_iduuidapp_iduuidevent_idstring, 8 to 128 charssdk_versionstringsignaturestring, exactly 64 hex charssent_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.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.
| 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. |
/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.
/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. |
/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.
measura_click_id to the destination so the SDK can attribute the resulting install. When the link has an in-app path and the destination opens your app directly, rather than a store page, measura_path is appended too.referrer parameter, which survives the install and gives deterministic attribution. It carries the click ID and nothing else: deferred parameters are exchanged for it afterwards at /deferred-link, so your campaign data never appears in a URL that Play truncates and anyone holding the link can read.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. |
/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.
resourcerequiredapp_idoptionaldate_fromoptionaldate_tooptional| 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 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. |
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 payment, refund, partial refund, chargeback or dispute 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, stripe or generic. The body is your provider's own payload, forwarded unmodified.
Rate limited per app
app_id. Exceeding it returns 429 with a Retry-After header. A burst of legitimate webhooks - a flash sale, a batch of subscription renewals - stays well under this; it exists to bound abuse of a public, unauthenticated-by-JWT endpoint.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.
Every provider signs differently, so the header and algorithm depend on which one you configured. In every case 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 |
| stripe | stripe-signature | HMAC-SHA256 of {timestamp}.{raw body}, hex, keyed with your Stripe webhook signing secret. The header carries the timestamp as t=...,v1=...; a request timestamped more than 5 minutes away from now is rejected. |
| generic | x-measura-signature | HMAC-SHA256 of the raw body, hex, keyed with the secret Measura issued |
Sign the bytes, not the object
Every recorded payment has an event_type: payment, refund, partial_refund, chargeback, or dispute. A reversal is a separate, immutable record linked to the original payment where Measura can determine the link - it never overwrites or deletes the original. Revenue reporting nets these against each other rather than summing every row blindly.
| Provider | How a reversal is signalled |
|---|---|
| paystack | event: "refund.processed" |
| flutterwave | event: "refund.completed" |
| stripe | type: "charge.refunded" (full or partial, by amount) or type: "charge.dispute.created" |
| generic | You set event_type explicitly in your own payload. Defaults to payment if omitted - Measura has no vendor convention to infer it from for your own integration. |
Redelivering the same event - same provider, same reference, same event_type, scoped to your account - is safely ignored. Measura answers {"status": "duplicate"} with 200 rather than recording it twice. Providers retry on any non-2xx, so redelivery is expected and normal, not an error condition.
{
"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 | Event stored and, for a payment, attribution attempted. |
200 duplicate | This provider reference and event_type were already recorded. Providers retry, so redelivery is expected and safe. |
200 ignored | A valid event that is not a recognised payment or reversal, 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. |
429 | Rate limit exceeded for this app_id. Retry after the interval in the Retry-After header. |
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.Paystack and Stripe send minor units (kobo, cents); Measura converts on the way in, so 499900 kobo is stored as 4999.00. Flutterwave sends major units already and is stored as received. Revenue totals are always reported per currency and never summed across them, because adding NGN to USD produces a number that means nothing.
This is the reason a mobile measurement partner exists. An ad network will not optimise a campaign it cannot measure, and it will not take your word for which installs it caused. So Measura attributes the install and reports it back to the network you configured, in the format that network actually accepts.
There is no endpoint to call for this
Each network you configure has a postback_format, which decides the wire shape Measura sends:
| Format | Network | Required settings |
|---|---|---|
measura_canonical | Any network, via your own URL | None. Measura posts its own JSON to the URL you provide. |
meta_capi | Meta, WhatsApp Business | dataset_id, api_version, event_name |
google_cm360 | Google Campaign Manager 360 | profile_id, floodlight_configuration_id, floodlight_activity_id |
snap_capi | Snapchat | pixel_id, event_name |
tiktok_events | TikTok | event_source_id, event_name |
whatsapp_business is not a separate wire format. Click-to-WhatsApp ad attribution is measured through Meta's own Conversions API, so a WhatsApp Business config uses meta_capi with WhatsApp-specific event naming, the same integration as a plain Meta config.
X/Twitter has no native format, and none is planned
measura_canonical to your own URL - there is nothing Measura can call on X's behalf directly.TikTok requires TikTok's own approval
tiktok_events will actually be accepted. This is a requirement on TikTok's side, independent of your configuration.None of the native formats (Meta, Google, Snapchat, TikTok) have been verified against a live vendor account. Each request is built exactly to the vendor's published contract and covered by tests that pin the wire format byte for byte, but the only way to confirm a specific network accepts it is to configure your real credentials and check that network's own delivery log.
When a config uses measura_canonical, this is exactly what arrives at your URL:
{
"event_type": "install",
"attribution_id": "b7e1...",
"install_id": "9a02...",
"installed_at": "2026-09-08T10:00:00.000Z",
"model": "last_click",
"confidence_score": 87,
"click_id": "c441...",
"campaign_id": "8f10...",
"gaid": "38400000-8cf0-11bd-b23e-10b96e40000d",
"measura_customer_id": "1d55...",
"timestamp": 1788800000000
}A purchase postback carries the same envelope with event_type: "purchase" plus value and currency. Purchase postbacks fire when a payment recorded through the payment webhook below resolves to a device that already has an attributed install.
A network config opts into install, purchase, or both. Choosing purchase is what makes revenue-based campaign optimisation possible on the network side, rather than install counts alone.
Delivery is attempted roughly every 60 seconds. A failure is classified before Measura decides whether to retry it:
| Failure | Classification | Behaviour |
|---|---|---|
| A vendor 4xx other than 429 (bad credential, malformed field, revoked token) | Permanent | No retry. The identical request cannot produce a different vendor decision. |
| HTTP 429, a 5xx, or a timeout | Transient | Retried with growing backoff (60s, 120s, 300s, 600s, then 900s), up to 5 attempts. |
| A missing credential or malformed setting | Permanent | No retry. Recorded distinctly so a half-configured network is not read as the network being down. |
After the final attempt, the attribution's postback status is marked failed for manual review. A successful delivery to at least one configured network marks it sent.
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. |
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.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}'| 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, Flutterwave and Stripe require the secret from their own dashboard; generic generates one for you.
| 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.
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.
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. |
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. |
Kotlin, minimum SDK 21. The release archive measures under 100 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 312 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
)
)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()Boolean| 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.
The SDK is built to survive poor connectivity without losing events. Understanding the deferral rules explains most reports of missing data.
In every case below the event is written to persistent storage rather than dropped.
wifiOnlyMode is on and the device is on cellular. This is the default.| 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.