Billing
Entitlements
Gate features and meter usage with a single API call. Define features per product in the dashboard, then ask Merida the runtime question your app cares about: 'Is this customer allowed to do X right now?'
Why entitlements
A subscription tells you the customer paid. An entitlement tells you what they're allowed to do with it.
- Boolean features: feature toggles per plan (
web_access,priority_support). - Metered features: usage with a per-cycle allowance (175 calls/month) and optional overage pricing.
- Credits: pre-paid balance that decrements as customers consume.
Without entitlements you'd build the counter table, the reset cron, the
rollover math, and the "you hit your limit" code path yourself. With
entitlements you call /check and /track, and Merida holds the
state.
Concepts
MeterresourceA (event filter, aggregation) pair that turns raw events into a numeric
reading. Identified by a stable slug. Example: a meter with
event_name: "ai_call" and aggregation: "sum" over the
total_tokens field.
Feature keystringThe stable identifier your app passes to /check and /track.
Per-product allowances let "calls" mean 100 on Lite and 175 on Pro
without forking the key.
Product entitlementresourceAttaches a feature to a product with allowance rules
(FIXED/UNLIMITED/NONE), reset interval, optional rollover, and
optional overage price. Configure under
Dashboard → Products → (edit) → Entitlements.
Customer balancestateMaterialized when a subscription starts and re-credited on each cycle reset. Track decrements consumed, and check reads remaining.
Step 1: Define a meter
Meters are the source of truth for "how do I count this?". Create one per thing your customers consume: API calls, tokens, seats, GB.
curl -X POST https://api.meridapay.com/meters \
-H "Authorization: Bearer $MERIDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "AI Calls",
"slug": "calls",
"aggregation": "sum",
"event_name": "ai_call",
"value_property": "tokens",
"unit_label": "tokens"
}'
slugstringrequiredLowercase, alphanumeric + _-. Reuse this as your feature_key.
aggregationenumrequiredsum · count · max · last · unique_count. count ignores
value_property.
event_namestringrequiredThe name field on events that should feed this meter.
value_propertystringWhich metadata field carries the numeric value. Omit when events use
the top-level value field.
filterobjectOptional metadata filter. { "model": "gpt-4" } means only events
with metadata.model = "gpt-4" count.
Step 2: Attach the feature to your products
Open a product's edit page in the dashboard. Click Attach feature, pick the meter, and set the allowance:
- Type: Boolean / Metered / Credit
- Allowance: 175 (per period)
- Reset: per billing cycle / monthly / daily / no reset
- Rollover (optional): carry unused → next period, with optional cap
- Overage price (optional): a
product_pricethat bills consumption beyond allowance
You can attach the same feature to multiple products with different
allowances: calls on the Lite product can grant 100 while the same
calls key on Pro grants 175. Your app code stays a single
check({ feature_key: "calls" }) call.
Or do it via API:
curl -X POST https://api.meridapay.com/products/$PRODUCT_ID/entitlements \
-H "Authorization: Bearer $MERIDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"feature_key": "calls",
"feature_type": "METERED",
"meter_id": "<meter-uuid>",
"allowance_type": "FIXED",
"allowance": "175",
"interval_type": "CYCLE",
"rollover_enabled": true,
"rollover_cap": "300",
"overage_price_id": "<product-price-uuid>"
}'
Step 3: Gate every request with /check
Call this on the hot path. One round trip returns the runtime answer plus enough context to render an upgrade CTA.
const res = await fetch("https://api.meridapay.com/check", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.MERIDA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_customer_id: orgId,
feature_key: "calls",
value: 1, // optional; used to project the next call
}),
});
const { allowed, balance, limit, consumed, reason } = await res.json();
if (!allowed) {
return new Response("Quota exceeded. Upgrade to keep going.", { status: 402 });
}
Response shape:
allowedbooleantrue if the customer can use the feature. Boolean features return
true when granted. Metered and credit return true when remaining ≥
value, or unconditionally when an overage_price_id is set.
balancenumberRemaining units in the current period. null for booleans.
limitnumberTotal available this period (credited + rollover).
consumednumberUsed so far this period.
reasonenumgranted · within_allowance · overage · exhausted ·
no_active_entitlement · customer_not_found. Useful for analytics.
Step 4: Record consumption with /track
Fire-and-forget after a successful action. The feature key resolves to a meter (matching slugs) and the call is converted into one event.
await fetch("https://api.meridapay.com/track", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.MERIDA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_customer_id: orgId,
feature_key: "calls",
value: 1,
idempotency_key: callId, // optional; dedupes safely on retry
metadata: { model: "gpt-4" },
}),
});
Send the idempotency_key whenever the call sits behind a queue or
webhook retry. Merida will silently treat duplicates as no-ops.
Bulk events: /events/ingest
When you control event aggregation yourself or want to backfill, post a
batch of raw events. Each event matches every meter whose event_name
and filter agree, and updates the corresponding customer_meters row.
curl -X POST https://api.meridapay.com/events/ingest \
-H "Authorization: Bearer $MERIDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"name": "ai_call",
"external_customer_id": "user_123",
"value": 850,
"metadata": { "model": "gpt-4" },
"external_id": "msg_abc123"
}
]
}'
Up to 500 events per request. The response reports inserted and
duplicates (matched on external_id).
Reading customer balances
For dashboards, "usage so far this month" widgets, or admin tools:
curl https://api.meridapay.com/customers/$EXTERNAL_CUSTOMER_ID/balances \
-H "Authorization: Bearer $MERIDA_API_KEY"
Returns one row per (customer, meter) for the current period:
{
"data": [
{
"meter_id": "…",
"meter_slug": "calls",
"meter_name": "AI Calls",
"unit_label": "tokens",
"period_start": "2026-05-01T00:00:00Z",
"period_end": "2026-06-01T00:00:00Z",
"credited": 175000,
"rollover": 0,
"consumed": 42100,
"balance": 132900,
"is_exhausted": false
}
]
}
Lifecycle: when balances appear and reset
Balances are projected onto the customer the moment a subscription becomes active, so there's no cold start where they show zero. Specifically:
- On subscription create / checkout success: Merida walks the product's
entitlements and writes a
customer_metersrow withcredited = allowanceandconsumed = 0. The next/checkreturns the full quota. - On every cycle anniversary: an hourly cron rolls the period forward.
consumedresets to zero,creditedis reset to the allowance, androllovercarries any unused balance from the prior period (capped byrollover_capwhen set). - On exhaustion: when
consumed ≥ credited + rollover, a future webhook event will fire (entitlement.exhausted) so you can prompt the customer to upgrade.
Picking the right primitive
| You want | Use |
| --- | --- |
| "Does this customer have web access?" | Boolean feature |
| "Has this customer used 175 calls this month?" | Metered feature |
| "Top up the customer's wallet by 1,000 credits when they buy a credit pack" | Credit feature + a one-time top-up product whose entitlement grants the same feature_key |
| "Bill any usage past 175 calls at $2.50 each" | Metered feature with overage_price_id set to a metered product price |
| "I want a free tier with no quota, just a flag" | Boolean feature on the free product |
Errors
| Status | When |
| --- | --- |
| 401 | Missing or revoked API key |
| 400 | Body validation failed (see error.issues) |
| 404 | Product not found, or feature_key is not attached to any product in your org (catches typos in app code) |
| 200 with allowed: false | Customer is real but exhausted or has no active subscription on a product that grants this feature |
The 404 vs 200 split is deliberate: a typo'd feature_key (saige_proo instead of saige_pro) surfaces immediately, instead of looking like every customer is unsubscribed.