Usage Records
curl --request POST \
--url https://api.launchmystore.io/api/v1/billing/usage \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"quantity": 123,
"idempotencyKey": "<string>",
"cappedAmount": 123,
"returnUrl": "<string>"
}
'import requests
url = "https://api.launchmystore.io/api/v1/billing/usage"
payload = {
"quantity": 123,
"idempotencyKey": "<string>",
"cappedAmount": 123,
"returnUrl": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
quantity: 123,
idempotencyKey: '<string>',
cappedAmount: 123,
returnUrl: '<string>'
})
};
fetch('https://api.launchmystore.io/api/v1/billing/usage', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.launchmystore.io/api/v1/billing/usage",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'quantity' => 123,
'idempotencyKey' => '<string>',
'cappedAmount' => 123,
'returnUrl' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.launchmystore.io/api/v1/billing/usage"
payload := strings.NewReader("{\n \"quantity\": 123,\n \"idempotencyKey\": \"<string>\",\n \"cappedAmount\": 123,\n \"returnUrl\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.launchmystore.io/api/v1/billing/usage")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"quantity\": 123,\n \"idempotencyKey\": \"<string>\",\n \"cappedAmount\": 123,\n \"returnUrl\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.launchmystore.io/api/v1/billing/usage")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"quantity\": 123,\n \"idempotencyKey\": \"<string>\",\n \"cappedAmount\": 123,\n \"returnUrl\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"data.recordedAt": "<string>",
"data.quantity": 123,
"data.unitAmount": {},
"data.amountCents": 123,
"data.accruedAmountCents": 123,
"data.capAmountCents": {},
"data.remainingCents": {},
"data.stripeUsageRecordId": "<string>",
"data.unitName": {},
"data.currentPeriodEnd": {},
"data.meteredItemId": {}
}App Billing
Usage Records
Record metered (usage-based) charges and inspect the current cap, accrued spend, and remaining headroom for an installation.
POST
/
api
/
v1
/
billing
/
usage
Usage Records
curl --request POST \
--url https://api.launchmystore.io/api/v1/billing/usage \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"quantity": 123,
"idempotencyKey": "<string>",
"cappedAmount": 123,
"returnUrl": "<string>"
}
'import requests
url = "https://api.launchmystore.io/api/v1/billing/usage"
payload = {
"quantity": 123,
"idempotencyKey": "<string>",
"cappedAmount": 123,
"returnUrl": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
quantity: 123,
idempotencyKey: '<string>',
cappedAmount: 123,
returnUrl: '<string>'
})
};
fetch('https://api.launchmystore.io/api/v1/billing/usage', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.launchmystore.io/api/v1/billing/usage",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'quantity' => 123,
'idempotencyKey' => '<string>',
'cappedAmount' => 123,
'returnUrl' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.launchmystore.io/api/v1/billing/usage"
payload := strings.NewReader("{\n \"quantity\": 123,\n \"idempotencyKey\": \"<string>\",\n \"cappedAmount\": 123,\n \"returnUrl\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.launchmystore.io/api/v1/billing/usage")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"quantity\": 123,\n \"idempotencyKey\": \"<string>\",\n \"cappedAmount\": 123,\n \"returnUrl\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.launchmystore.io/api/v1/billing/usage")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"quantity\": 123,\n \"idempotencyKey\": \"<string>\",\n \"cappedAmount\": 123,\n \"returnUrl\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"data.recordedAt": "<string>",
"data.quantity": 123,
"data.unitAmount": {},
"data.amountCents": 123,
"data.accruedAmountCents": 123,
"data.capAmountCents": {},
"data.remainingCents": {},
"data.stripeUsageRecordId": "<string>",
"data.unitName": {},
"data.currentPeriodEnd": {},
"data.meteredItemId": {}
}The usage endpoints let an installed app charge per-event — one
credit per SMS sent, per AI generation, per shipping label printed —
on top of a flat monthly subscription. Each call records meter ticks for
the current billing period under the app’s metered Stripe subscription
item.
The metered component is declared on the app’s pricing plan
(
The
Open
pricing.usage in app.json, see
Usage Billing). Every usage event you POST is
reported to Stripe behind a unique subscription_item.id, the cost
(quantity × usageUnitAmount) is checked against the merchant-approved
cap, and a USAGE billing transaction is recorded so it
surfaces in the merchant’s billing detail page.
Auth: app access token (OAuth). The endpoint resolves the
installation from the token’s aud claim — apps never pass
installationId in the body.
| Endpoint | Scope | Purpose |
|---|---|---|
POST /api/v1/billing/usage | write_billing | Record N units of usage |
GET /api/v1/billing/usage | read_billing | Read current cap, accrued spend, period end |
POST /api/v1/billing/usage/cap | write_billing | Lower (immediate) or raise (approval) the cap |
Usage is checked against the cap before the Stripe call. If the
projected accrual would exceed the cap, the endpoint returns
402
with code: USAGE_CAP_EXCEEDED and no usage record is created. Apps
should treat that response as a hard signal to either stop the
underlying action or call POST /usage/cap to request a raise.Record a usage event
POST /api/v1/billing/usage
integer
required
Integer number of units consumed by this event. Must be
>= 1. Reject
fractional values — scale your metered unit instead (tokens rather
than kilo_tokens).string
Optional client-supplied key. Forwarded to Stripe as the
subscriptionItems.createUsageRecord idempotency key (namespaced to
the installation so collisions across apps are impossible). Retries
with the same key are always safe; without one, double-billing is
possible on network retries.curl -X POST "https://api.launchmystore.io/api/v1/billing/usage" \
-H "Authorization: Bearer <APP_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"quantity": 1,
"idempotencyKey": "sms-msg-7c2f1c"
}'
Response
string
ISO timestamp Stripe used for the usage record.
integer
Echo of the recorded quantity.
number
Per-unit price from the app’s pricing plan (dollars).
integer
Cost of this event (
quantity × unitAmount × 100).integer
Running total accrued in the current billing period after this event.
integer | null
Merchant-approved cap in cents (
null if uncapped).integer | null
Cap headroom after this event (
null if uncapped).string
Stripe
mbur_… usage-record id (also stored on the billing transaction).{
"status": 200,
"state": "success",
"data": {
"recordedAt": "2026-05-17T18:42:11.000Z",
"quantity": 1,
"unitAmount": 0.05,
"amountCents": 5,
"accruedAmountCents": 605,
"capAmountCents": 5000,
"remainingCents": 4395,
"stripeUsageRecordId": "mbur_1OqW2NABCxyz"
}
}
Cap exceeded
When the projected accrual would exceed the cap, the endpoint returns402 and no usage is recorded:
{
"status": 402,
"state": "error",
"message": "{\"code\":\"USAGE_CAP_EXCEEDED\",\"capCents\":1000,\"accruedCents\":600,\"remainingCents\":400}"
}
message field is a JSON-encoded payload — parse it client-side to
decide how to respond. code: USAGE_CAP_EXCEEDED is stable; the cents
fields let you compute how much headroom is left and prompt the
merchant to raise the cap via POST /usage/cap.
Get current usage state
GET /api/v1/billing/usage
Returns the metered configuration plus the current period’s accrual.
Apps typically call this from their admin home iframe to render a
“used X of Y this month” progress bar.
Response
string | null
Human label from the pricing plan, e.g.
SMS, email, AI generation.number | null
Per-unit price in dollars.
integer | null
Merchant-approved cap in cents (
null if uncapped).integer
Cents accrued so far this billing period.
integer | null
Cap headroom (
null if uncapped).string | null
ISO timestamp when the current Stripe period ends.
accruedAmountCents resets to 0 here.string | null
Stripe
si_… subscription-item id this usage attaches to.curl -X GET "https://api.launchmystore.io/api/v1/billing/usage" \
-H "Authorization: Bearer <APP_ACCESS_TOKEN>"
{
"status": 200,
"state": "success",
"data": {
"unitName": "SMS",
"unitAmount": 0.05,
"capAmountCents": 5000,
"accruedAmountCents": 605,
"remainingCents": 4395,
"currentPeriodEnd": "2026-06-01T00:00:00.000Z",
"meteredItemId": "si_OqW2NABCxyz"
}
}
Update the usage cap
POST /api/v1/billing/usage/cap
The cap is the merchant’s per-period spending ceiling on the metered
component. It defaults to the value declared in pricing.usage.cappedAmount
when the merchant first subscribes, and can be changed afterwards:
- Lowering the cap takes effect immediately, but cannot go below
what the merchant has already accrued this period (would create an
instant
USAGE_CAP_EXCEEDEDloop). - Raising the cap requires merchant approval. The endpoint returns
a Stripe Billing Portal
confirmationUrlthe merchant must visit to authorise the new cap; the row isn’t updated until they confirm.
number
required
Requested cap in dollars (integer cents on the wire is fine —
values are rounded).
0 is allowed and effectively disables further
usage charges until the cap is raised.string
URL the merchant returns to after confirming a raise in the Stripe
Billing Portal. Defaults to the app developer’s the admin origin.
curl -X POST "https://api.launchmystore.io/api/v1/billing/usage/cap" \
-H "Authorization: Bearer <APP_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"cappedAmount": 100,
"returnUrl": "https://my-app.example.com/billing/return"
}'
Response — immediate (lowering)
{
"status": 200,
"state": "success",
"data": {
"requiresApproval": false,
"newCap": 8
}
}
Response — approval required (raising)
{
"status": 200,
"state": "success",
"data": {
"requiresApproval": true,
"confirmationUrl": "https://billing.stripe.com/p/session/test_…",
"currentCap": 50,
"requestedCap": 100
}
}
confirmationUrl in a top-level browser tab (don’t iframe — Stripe
blocks framing the billing portal). After the merchant confirms, the
backend updates the row; the next POST /usage call sees the new cap.
Idempotency guidelines
- Mint a stable
idempotencyKeyper real-world event. A message-id, a generation-id, or a UUIDv4 you record alongside the event all work. - Keys are namespaced server-side as
usage:{installationId}:{key}and passed to Stripe as the canonical idempotency key. Stripe retains idempotency keys for 24 h, so retries within that window are safe. - Without a key, network retries can produce duplicate
UsageRecordrecords on Stripe and duplicate billing transactions (the unique index onstripeUsageRecordIdcatches the second insert, but you’ve still double-billed the merchant on Stripe’s side).
Error codes
| Code | When |
|---|---|
400 | quantity < 1, fractional, or non-integer. |
400 | Installation has no stripeMeteredItemId (app pricing has no usage component). |
400 | App has no usageUnitAmount configured. |
400 | cappedAmount below already-accrued spend (on /usage/cap). |
401 | Missing or invalid app access token. |
402 | Subscription not active or trialing — see billingStatus. |
402 | USAGE_CAP_EXCEEDED — see body for capCents / accruedCents / remainingCents. |
403 | Token lacks write_billing (POST) or read_billing (GET) scope. |
404 | Installation or app not found. |
See also
- Usage Billing — conceptual guide to declaring
metered pricing in
app.jsonand the full event → invoice flow. - Subscriptions — flat monthly subscription state.
- Transactions — every billing transaction (flat + usage + invoice).