REST API
The endpoints a client app (or the iOS / Android SDK) calls. Admin / dashboard endpoints are not covered here, with one exception: outbound webhooks, which have no UI yet.
Base URL
Every path below is relative to https://api.meterahq.com. That is the API; meterahq.com is the dashboard and does not serve these endpoints.
| API (SDK, REST) | https://api.meterahq.com |
| Dashboard (keys, plans, offerings) | https://meterahq.com |
Do not guess this host. Your app key rides on every request as Authorization: Bearer app_…, so a wrong base URL does not merely fail — it hands a live credential to whoever owns that domain. Metera is not metera.cn, metera.xyz, metera.io or metera.com; all four are third parties.
Authentication
Every request needs:
Authorization: Bearer <app API key> | The app's API key (app_…). |
X-App-User-Id: <appUserId> | The current user's ID. Optional on calls that already carry appUserId, but harmless to always send. |
All request and response bodies are JSON.
POST /v1/users/identify
Create or look up a user, optionally merging an anonymous identity into a known one. When anonymousId differs from appUserId, that user's subscriptions, purchases and entitlement grants move onto appUserId.
{
"appUserId": "user@example.com",
"anonymousId": "$meteraAnonymous:ABC",
"attributes": { "email": "user@example.com" }
}Response:
{
"appUserId": "user@example.com",
"merged": true,
"entitlements": {
"pro": {
"isActive": true,
"expiresAt": "2026-06-10T12:00:00.000Z",
"productId": "xyz.pytron.app.pro.monthly",
"source": "SUBSCRIPTION"
}
}
}The entitlements map here is the short form (isActive / expiresAt / productId / source). The full customer-info record is the next endpoint — do not expect the two shapes to match.
GET /v1/users/{appUserId}/entitlements
The canonical "customer info" — active entitlements and subscriptions. This is what gates features. URL-encode appUserId in the path. Returns 400 if the user does not exist yet — call identify first.
{
"appUserId": "user@example.com",
"firstSeen": "2026-01-04T09:12:44.000Z",
"originalAppUserId": "$meteraAnonymous:ABC",
"managementUrl": "https://play.google.com/store/account/subscriptions?sku=xyz.pytron.app.pro.monthly&package=xyz.pytron.app",
"entitlements": {
"pro": {
"identifier": "pro",
"isActive": true,
"willRenew": true,
"periodType": "NORMAL",
"latestPurchaseDate": "2026-05-10T12:00:00.000Z",
"originalPurchaseDate": "2026-05-10T12:00:00.000Z",
"expirationDate": "2026-06-10T12:00:00.000Z",
"productIdentifier": "xyz.pytron.app.pro.monthly",
"unsubscribeDetectedAt": null,
"billingIssueDetectedAt": null,
"store": "ANDROID",
"isSandbox": false
}
},
"activeSubscriptions": ["xyz.pytron.app.pro.monthly"],
"allPurchasedProductIds": ["xyz.pytron.app.pro.monthly"],
"latestExpirationDate": "2026-06-10T12:00:00.000Z"
}The entitlements map contains only currently-active entitlements. An expired or never-granted one is an absent key, not isActive: false. Gate on presence:
const res = await fetch(
`https://api.meterahq.com/v1/users/${encodeURIComponent(appUserId)}/entitlements`,
{ headers: { Authorization: `Bearer ${appKey}` } }
);
const info = await res.json();
// Presence IS the grant. The map holds only currently-active entitlements,
// so an expired one is an absent key — never { isActive: false }.
const isPro = info.entitlements["pro"] !== undefined;store and isSandbox are omitted when the underlying purchase cannot answer them — store when the grant came from neither store, and isSandbox on any grant not sourced from a subscription (a promotional or pack grant has no store environment). Treat an absent field as unknown, never as false: reading a missing isSandbox as "real money" is how a sandbox purchase gets booked as revenue.
GET /v1/offerings
The remote paywall configuration: which products to show, grouped into packages. Editable in the dashboard with no app release.
{
"currentOfferingId": "default",
"offerings": [{
"identifier": "default",
"displayName": "Default",
"isCurrent": true,
"packages": [{
"identifier": "$rc_monthly",
"displayName": "Monthly",
"productId": "xyz.pytron.app.pro.monthly",
"productType": "SUBSCRIPTION",
"billingPeriod": "MONTHLY"
}]
}]
}POST /v1/receipts/ios
Verify and sync a StoreKit 2 transaction. Metera independently re-verifies with the App Store Server API — the client is never trusted. Request: { "appUserId": "...", "transactionId": "2000000812345678" }. Returns 200 (synced) or 422 (Apple could not verify).
POST /v1/receipts/android
Verify and sync a Google Play purchase. Metera re-verifies the purchase token with the Play Developer API and acknowledges it. Request: { "appUserId": "...", "purchaseToken": "<token>" }. Returns 200 or 422.
Outbound webhooks
Metera pushes signed webhooks to your backend when a subscription or entitlement changes, so your own DB stays in sync without polling. Deliveries retry with exponential backoff for roughly 16 hours before dead-lettering.
There is no webhook screen in the dashboard yet — registration is not self-serve. The endpoint below is a dashboard-admin route, reachable only with a signed-in dashboard session. Until the UI ships you have two options: run the snippet below from your browser, or email hello@meterahq.com with your app ID and target URL and we will register it for you. There is no API token that lets a server do this on your behalf; the app key (app_…) does not authenticate admin routes.
// Paste into the browser console on https://meterahq.com, signed in.
// Same-origin, so your dashboard session cookie authenticates the call.
await fetch("/api/admin/apps/<appId>/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://your-backend.example.com/metera/webhooks",
// Omit eventTypes entirely to receive every event.
eventTypes: ["subscription.updated", "entitlement.updated"],
}),
}).then((r) => r.json());Responds 201. The signing secret is returned only here, only once — it is redacted from every subsequent read, so if you lose it you must delete the endpoint and create a new one.
{
"webhook": {
"id": "...",
"url": "https://your-backend.example.com/metera/webhooks",
"secret": "whsec_…", // shown ONCE, at creation — store it now
"eventTypes": ["subscription.updated", "entitlement.updated"],
"isActive": true
}
}Valid eventTypes are subscription.updated (purchase, renewal, cancel, expiry, refund) and entitlement.updated (a non-subscription grant changed). Any other value is rejected. The same route supports GET /api/admin/apps/{appId}/webhooks to list, PATCH /api/admin/webhooks/{id} to change the URL / events / isActive, and DELETE /api/admin/webhooks/{id} to remove one.
Receiving an event
POST https://your-backend.example.com/metera/webhooks
X-Metera-Event: subscription.updated
X-Metera-Delivery-Id: <delivery id>
X-Metera-Signature: t=1770000000,v1=<hex hmac>
{
"id": "<delivery id>",
"type": "subscription.updated",
"appId": "<appId>",
"createdAt": "2026-06-10T12:00:00.000Z",
"data": { }
}Reply 2xx promptly (the request times out after 10s) and do your work asynchronously. Any non-2xx or timeout is retried, so your handler must be idempotent — X-Metera-Delivery-Id is stable across retries of the same event, so de-dupe on it.
Verifying the signature
X-Metera-Signature is t=<unix seconds>,v1=<hex>, where the hex is an HMAC-SHA256 of {t}.{raw body} keyed by your whsec_… secret. Reject anything that does not verify.
import { createHmac, timingSafeEqual } from "crypto";
// Sign the RAW body — re-serializing a parsed object changes the bytes and
// the HMAC will not match.
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(
header.split(",").map((p) => p.split("="))
);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay guard
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b);
}Errors
Non-2xx responses carry an error field — either a string or { "code", "message" }. Treat 4xx as terminal; 5xx and network failures are safe to retry with backoff (both SDKs do).