Topups
A customer buys credits once. No subscription is involved.
A topup is one purchase. The customer pays, and you give them credits once. Nothing repeats.
For a manual integration, your provider tells you the money arrived, and then
you call grantTopup. That call records the purchase and adds the credits in
one step.
A payment connector uses a different path.
QuotaStack creates hosted checkout and applies the mapped package after a
verified provider event. Do not also call grantTopup for that checkout.
Two ways to run this. Define a package when you sell the same bundle over and
over, so the credit amount lives in one place. Skip the package and send
credits directly when the amount changes every time.
A package is only a template. Creating one grants nothing. Retiring one with
is_active: false blocks new grants, and never touches credits you already
granted.
Send credits on every grant, even when you name a package_id. The package
records which bundle was bought. The field never fills in the amount for you.
Topup credits burn before plan credits. A topup block sits at priority 0 and a plan grant sits at 10, so QuotaStack spends the money the customer paid for first.
Want credits to arrive again every month? That is a subscription, not a topup.
Which call do I want?
- You sell a fixed bundle of credits, and you want to name it once →
POST /v1/topup-packages - A customer paid, and you are giving them the credits →
POST /v1/topups/grantA topup is one purchase. A subscription grants credits again every period. Use this call when the customer pays once. - You are retiring a bundle and want to stop new purchases of it →
PATCH /v1/topup-packages/{id}Setis_activeto false. Old grants keep their credits; only new grants are blocked. - You want to see what one customer has bought →
GET /v1/customers/{customer_id}/topupsTwo endpoints do this.listCustomerTopupstakes the QuotaStackcustomer_id.listCustomerTopupsByExternalIdtakes your own ID. Only the lookup differs; the reply is the same. - You want the bundles you have defined, not what anyone bought →
GET /v1/topup-packages
List topup packages
/v1/topup-packages | Idempotency-Key | Not used. This is a read. |
|---|---|
| Environment | Sandbox and Live |
| Auth | Tenant API key |
| Writes | Nothing. A read has no side effects. |
| Fires | No webhooks. |
This call returns the bundles you have defined. The list holds no purchases at all. Use listCustomerTopups to see what a customer bought. QuotaStack hides packages you turned off, unless you ask for them. Send active_only=false to see every package. openapi.yaml says this one defaults to false. The spec is wrong here. The real default is true. A cursor QuotaStack cannot read returns 400.
| Field | Type | Meaning |
|---|---|---|
active_onlyoptional | boolean | Whether to hide packages you turned off. If you leave it out: QuotaStack hides those packages. The spec says the default is |
cursoroptional | string | Opaque pagination cursor from the previous page's |
limitoptional | integer | Page size. Default 20, max 100. |
{
"data": [
{
"id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
"name": "10,000 credits",
"credits": 10000000,
"is_active": true,
"expires_after_seconds": null,
"metadata": {},
"created_at": "2026-07-28T09:00:00Z",
"updated_at": "2026-07-28T09:00:00Z"
}
],
"pagination": {
"has_more": false,
"next_cursor": null
}
} | Field | Type | Meaning |
|---|---|---|
datarequired | array | |
data.namerequired | string | What you call this package. QuotaStack shows it back to you, and never to your customer. |
data.creditsrequired | integer (int64) | How many credits one purchase of this package grants. Unit: millicredits, where 1 credit is 1000 millicredits. |
data.is_activerequired | boolean | Whether you can still grant this package. An inactive package stays readable and stops being grantable. If you leave it out: A new package is active. |
data.expires_after_secondsoptional | integer (int64) | How long the credits from this package last, counted from the moment you grant them. QuotaStack uses this only when the grant itself names no expiry. If you leave it out: The credits never expire. No request field sets this today, so a package you create through this API always reads |
data.metadatarequired | object | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
paginationrequired | object | Cursor-based pagination envelope. Returned on every list endpoint. No |
See also
Create a topup package
/v1/topup-packages | Idempotency-Key | Required. Returns 422 without it. |
|---|---|
| Environment | Sandbox and Live |
| Auth | Tenant API key |
| Writes | One new topup package. No customer is touched, and no credits move. |
| Fires | No webhooks. |
This call defines a bundle you sell more than once. Name it, say how many credits it grants, and grant it later with grantTopup. A package is a template. Creating one grants nothing and charges nobody. You do not need a package at all — grantTopup works without one. Use a package when you sell the same bundle repeatedly and want one place to change it. QuotaStack cannot set an expiry on a package through this API. The reply carries expires_after_seconds, and no request field writes it. Put the expiry on the grant instead. openapi.yaml declares only 201 here. A missing name, or credits at zero or below, returns 422, and the error names the field. A body QuotaStack cannot read returns 400.
| Field | Type | Meaning |
|---|---|---|
Idempotency-Keyrequired | string | A unique string you choose, up to 256 characters. It stops a retry from doing the work twice. This header is required on every POST and PATCH. Leave it out and the request fails with Send the same key twice and QuotaStack replays the first response. The second call's status and body match the first, and the response carries Derive the key from the business event rather than a random value, so a retry from anywhere reuses it — for example |
| Field | Type | Meaning |
|---|---|---|
namerequired | string | What you call this package. QuotaStack shows it back to you, and never to your customer. If you leave it out: QuotaStack rejects the call with |
creditsrequired | integer (int64)1–∞ | How many credits one purchase of this package grants. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: QuotaStack rejects the call with |
metadataoptional | object | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
{
"name": "10,000 credits",
"credits": 10000000
} {
"id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
"name": "10,000 credits",
"credits": 10000000,
"is_active": true,
"expires_after_seconds": null,
"metadata": {},
"created_at": "2026-07-28T09:00:00Z",
"updated_at": "2026-07-28T09:00:00Z"
} | Field | Type | Meaning |
|---|---|---|
idrequired | string (uuid) | |
tenant_idrequired | string (uuid) | |
namerequired | string | What you call this package. QuotaStack shows it back to you, and never to your customer. |
creditsrequired | integer (int64) | How many credits one purchase of this package grants. Unit: millicredits, where 1 credit is 1000 millicredits. |
is_activerequired | boolean | Whether you can still grant this package. An inactive package stays readable and stops being grantable. If you leave it out: A new package is active. |
expires_after_secondsoptional | integer (int64) | How long the credits from this package last, counted from the moment you grant them. QuotaStack uses this only when the grant itself names no expiry. If you leave it out: The credits never expire. No request field sets this today, so a package you create through this API always reads |
metadatarequired | object | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
created_atrequired | string (date-time) | |
updated_atrequired | string (date-time) |
See also
Update a topup package
/v1/topup-packages/{id} | Idempotency-Key | Required. Returns 422 without it. |
|---|---|
| Environment | Sandbox and Live |
| Auth | Tenant API key |
| Writes | The package itself. No customer is touched, and no credits move. |
| Fires | No webhooks. |
This call changes a package you already defined. You can change name, is_active, and metadata. You cannot change credits. Create a new package when the bundle size changes, so old purchases keep their real number. Setting is_active to false stops new grants of this package. Credits already granted from it stay exactly where they are. openapi.yaml declares only 200 here. An id you do not own returns 404. A body QuotaStack cannot read returns 400.
| Field | Type | Meaning |
|---|---|---|
Idempotency-Keyrequired | string | A unique string you choose, up to 256 characters. It stops a retry from doing the work twice. This header is required on every POST and PATCH. Leave it out and the request fails with Send the same key twice and QuotaStack replays the first response. The second call's status and body match the first, and the response carries Derive the key from the business event rather than a random value, so a retry from anywhere reuses it — for example |
| Field | Type | Meaning |
|---|---|---|
idrequired | string (uuid) |
| Field | Type | Meaning |
|---|---|---|
nameoptional | string | What you call this package. QuotaStack shows it back to you, and never to your customer. If you leave it out: The name stays as it was. |
is_activeoptional | boolean | Whether the package can still be granted. Set it to false to retire the bundle. If you leave it out: The setting stays as it was. |
metadataoptional | object | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
{
"is_active": false
} {
"id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
"name": "10,000 credits",
"credits": 10000000,
"is_active": false,
"expires_after_seconds": null,
"metadata": {},
"created_at": "2026-07-28T09:00:00Z",
"updated_at": "2026-07-28T11:00:00Z"
} | Field | Type | Meaning |
|---|---|---|
idrequired | string (uuid) | |
tenant_idrequired | string (uuid) | |
namerequired | string | What you call this package. QuotaStack shows it back to you, and never to your customer. |
creditsrequired | integer (int64) | How many credits one purchase of this package grants. Unit: millicredits, where 1 credit is 1000 millicredits. |
is_activerequired | boolean | Whether you can still grant this package. An inactive package stays readable and stops being grantable. If you leave it out: A new package is active. |
expires_after_secondsoptional | integer (int64) | How long the credits from this package last, counted from the moment you grant them. QuotaStack uses this only when the grant itself names no expiry. If you leave it out: The credits never expire. No request field sets this today, so a package you create through this API always reads |
metadatarequired | object | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
created_atrequired | string (date-time) | |
updated_atrequired | string (date-time) |
See also
Grant a topup
/v1/topups/grant | Idempotency-Key | Required. Returns 422 without it. |
|---|---|
| Environment | Sandbox and Live |
| Auth | Tenant API key |
| Writes | One topup record, and one new credit block for the customer. QuotaStack writes the sale down first, then grants the credits. A grant that fails after that step leaves a sale marked completed with no credits behind it. QuotaStack does this on purpose. A failure leaves a paper trail, instead of free credits. So read the balance when you check your books. The sale list alone can lie to you. |
| Fires | credit.granted |
This manual-integration call records a purchase and adds the credits. Call it after your payment provider tells you the money arrived. Do not call it for a payment-connector checkout. That path applies its mapped package after a verified provider event. Send customer_id or external_customer_id, never both. QuotaStack rejects the call with neither. An unrecognized value on either field creates a new customer, the same lookup grantCustomerCredits uses. Send credits every time, even when you send package_id. QuotaStack rejects a grant whose credits is zero or missing, before it ever reads the package. So package_id alone does not grant that package's credits. package_id records which bundle was bought, and sets the expiry when the package has one and the grant names none. Do not send expires_at and duration_seconds together. QuotaStack returns 422, the error names duration_seconds, and nothing is granted. stack_after requires duration_seconds. Send it without one and QuotaStack returns the same 422. stack_after with fallback: reject and no matching block returns 409, and nothing is granted. This reply is not the plain Topup the field table below describes. The real reply adds a nested account with balance and lifetime_earned. openapi.yaml declares only 201 and 404 here. A bad credits value, or those mutually exclusive fields, returns 422. Sending both customer fields, or neither, returns 400.
| Field | Type | Meaning |
|---|---|---|
Idempotency-Keyrequired | string | A unique string you choose, up to 256 characters. It stops a retry from doing the work twice. This header is required on every POST and PATCH. Leave it out and the request fails with Send the same key twice and QuotaStack replays the first response. The second call's status and body match the first, and the response carries Derive the key from the business event rather than a random value, so a retry from anywhere reuses it — for example |
| Field | Type | Meaning |
|---|---|---|
customer_idoptional | string | QuotaStack's own ID for the customer who bought this. If you leave it out: Send |
external_customer_idoptional | string | Your own ID for the customer who bought this. If you leave it out: Send |
package_idoptional | string (uuid) | Which bundle the customer bought. QuotaStack stores it on the topup. QuotaStack uses the package expiry when the grant names none. The field never fills in If you leave it out: The sale belongs to no package. Send |
creditsrequired | integer (int64)1–∞ | How many credits to add. Must be more than zero. Unit: millicredits, where 1 credit is 1000 millicredits. If you leave it out: QuotaStack rejects the call with |
price_paidrequired | integer (int64)0–∞ | What the customer paid you, in your own money. The number is cash, not credits. QuotaStack stores it for your records, and prices nothing from it. Unit: the minor unit of If you leave it out: QuotaStack stores 0. The credits are still granted. |
currencyrequired | string | Which currency If you leave it out: QuotaStack stores an empty string and the call still succeeds. The spec marks this field required, and no code enforces it. |
external_payment_idoptional | string | The ID your own payment provider gave this charge. QuotaStack keeps it, so you can tie a topup back to the money. If you leave it out: QuotaStack stores nothing here. Matching this sale to a charge gets harder later. |
expires_atoptional | string (date-time) | When these credits stop counting toward the balance. If you leave it out: QuotaStack uses the package's |
duration_secondsoptional | integer (int64)1–315360000 | How long these credits last, counted from when the block starts. If you leave it out: QuotaStack uses |
stack_afteroptional | object | Starts these credits right after another block ends, instead of right now. QuotaStack finds that other block by matching If you leave it out: The credits start now, not after another block. |
stack_after.metadata_matchrequired | object | Key-value tags to match against a block. QuotaStack picks the one with the latest expiry as the anchor. If you leave it out: QuotaStack rejects the call. This field is required whenever you send |
stack_after.fallbackoptional | string | What to do when no block matches If you leave it out: By default, QuotaStack uses
|
priorityoptional | integer0–255 | Which credit block gets spent first. A lower number goes first. These credits sit at 0 and plan credits sit at 10. So the credits a customer paid for burn before the ones a plan gave away. If you leave it out: By default, it is 0. These credits burn before plan credits. |
metadataoptional | object | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
{
"external_customer_id": "user_42",
"package_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
"credits": 10000000,
"price_paid": 1000,
"currency": "USD",
"external_payment_id": "pay_xyz789"
} {
"id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a71",
"customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
"package_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
"environment": "live",
"credits_granted": 10000000,
"price_paid": 1000,
"currency": "USD",
"external_payment_id": "pay_xyz789",
"expires_at": null,
"status": "completed",
"metadata": {},
"created_at": "2026-07-28T09:05:00Z",
"updated_at": "2026-07-28T09:05:00Z",
"account": {
"balance": 12000000,
"lifetime_earned": 22000000
}
} | Field | Type | Meaning |
|---|---|---|
idrequired | string (uuid) | |
tenant_idrequired | string (uuid) | |
customer_idrequired | string (uuid) | |
package_idoptional | string (uuid) | Which package this purchase came from. If you leave it out: The grant named no package. This topup is a one-off. |
environmentrequired | string |
Which environment this resource lives in. Determined by the API key prefix used ( |
credits_grantedrequired | integer (int64) | How many credits went to the customer. Unit: millicredits, where 1 credit is 1000 millicredits. |
price_paidrequired | integer (int64) | What the customer paid you, in your own money. The number is cash, not credits. QuotaStack stores it for your records, and prices nothing from it. Unit: the minor unit of |
currencyrequired | string | Which currency |
external_payment_idoptional | string | The ID you sent when you made this sale. QuotaStack keeps it word for word. |
expires_atoptional | string (date-time) | When these credits stop counting in the balance. If you leave it out: These credits never run out. |
statusrequired | string | Where this purchase stands.
|
metadatarequired | object | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
created_atrequired | string (date-time) | |
updated_atrequired | string (date-time) |
Errors
404— No package here has thispackage_id, or the package is inactive. QuotaStack sends the same reply for both, so checkis_activeyourself. not-found
List a customer topups
/v1/customers/{customer_id}/topups | Idempotency-Key | Not used. This is a read. |
|---|---|
| Environment | Sandbox and Live |
| Auth | Tenant API key |
| Writes | Nothing. A read has no side effects. |
| Fires | No webhooks. |
This call returns what one customer bought, newest first. The path takes the QuotaStack customer_id. Use listCustomerTopupsByExternalId when you hold your own ID instead. Both calls do the same work and send back the same shape. A topup here means QuotaStack wrote the sale down. The row is not proof that the credits landed. Read the balance when you need to be sure. A cursor QuotaStack cannot read returns 400.
| Field | Type | Meaning |
|---|---|---|
customer_idrequired | string | Customer identifier. Can be either a QuotaStack UUID or your own |
| Field | Type | Meaning |
|---|---|---|
cursoroptional | string | Opaque pagination cursor from the previous page's |
limitoptional | integer | Page size. Default 20, max 100. |
{
"data": [
{
"id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a71",
"customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
"package_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
"environment": "live",
"credits_granted": 10000000,
"price_paid": 1000,
"currency": "USD",
"external_payment_id": "pay_xyz789",
"expires_at": null,
"status": "completed",
"metadata": {},
"created_at": "2026-07-28T09:05:00Z",
"updated_at": "2026-07-28T09:05:00Z"
}
],
"pagination": {
"has_more": false,
"next_cursor": null
}
} | Field | Type | Meaning |
|---|---|---|
datarequired | array | |
data.package_idoptional | string (uuid) | Which package this purchase came from. If you leave it out: The grant named no package. This topup is a one-off. |
data.credits_grantedrequired | integer (int64) | How many credits went to the customer. Unit: millicredits, where 1 credit is 1000 millicredits. |
data.price_paidrequired | integer (int64) | What the customer paid you, in your own money. The number is cash, not credits. QuotaStack stores it for your records, and prices nothing from it. Unit: the minor unit of |
data.currencyrequired | string | Which currency |
data.external_payment_idoptional | string | The ID you sent when you made this sale. QuotaStack keeps it word for word. |
data.expires_atoptional | string (date-time) | When these credits stop counting in the balance. If you leave it out: These credits never run out. |
data.statusrequired | string | Where this purchase stands.
|
data.metadatarequired | object | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
paginationrequired | object | Cursor-based pagination envelope. Returned on every list endpoint. No |
See also
List a customer topups by external ID
/v1/customer-by-external-id/{external_id}/topups | Idempotency-Key | Not used. This is a read. |
|---|---|
| Environment | Sandbox and Live |
| Auth | Tenant API key |
| Writes | Nothing. A read has no side effects. |
| Fires | No webhooks. |
This call returns what one customer bought, looked up by your own ID. The call is the twin of listCustomerTopups. Both do the same work and send back the same shape. Only the lookup differs. QuotaStack matches the external_id exactly, and never falls back to the QuotaStack UUID. A cursor QuotaStack cannot read returns 400.
| Field | Type | Meaning |
|---|---|---|
external_idrequired | string | Your own ID for this customer, set when you created it. QuotaStack looks up only this value, never the QuotaStack UUID. If you leave it out: QuotaStack rejects the call. This path segment is required. |
| Field | Type | Meaning |
|---|---|---|
cursoroptional | string | Opaque pagination cursor from the previous page's |
limitoptional | integer | Page size. Default 20, max 100. |
{
"data": [
{
"id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a71",
"customer_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a05",
"package_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
"environment": "live",
"credits_granted": 10000000,
"price_paid": 1000,
"currency": "USD",
"external_payment_id": "pay_xyz789",
"expires_at": null,
"status": "completed",
"metadata": {},
"created_at": "2026-07-28T09:05:00Z",
"updated_at": "2026-07-28T09:05:00Z"
}
],
"pagination": {
"has_more": false,
"next_cursor": null
}
} | Field | Type | Meaning |
|---|---|---|
datarequired | array | |
data.package_idoptional | string (uuid) | Which package this purchase came from. If you leave it out: The grant named no package. This topup is a one-off. |
data.credits_grantedrequired | integer (int64) | How many credits went to the customer. Unit: millicredits, where 1 credit is 1000 millicredits. |
data.price_paidrequired | integer (int64) | What the customer paid you, in your own money. The number is cash, not credits. QuotaStack stores it for your records, and prices nothing from it. Unit: the minor unit of |
data.currencyrequired | string | Which currency |
data.external_payment_idoptional | string | The ID you sent when you made this sale. QuotaStack keeps it word for word. |
data.expires_atoptional | string (date-time) | When these credits stop counting in the balance. If you leave it out: These credits never run out. |
data.statusrequired | string | Where this purchase stands.
|
data.metadatarequired | object | Your own key-value tags. QuotaStack stores them unchanged and returns them on every read. If you leave it out: An empty object is stored instead. |
paginationrequired | object | Cursor-based pagination envelope. Returned on every list endpoint. No |
See also
Loading…