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).