quotastack Docs
Docs / API / Topups

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 oncePOST /v1/topup-packages
  • A customer paid, and you are giving them the creditsPOST /v1/topups/grant
    A 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 itPATCH /v1/topup-packages/{id}
    Set is_active to false. Old grants keep their credits; only new grants are blocked.
  • You want to see what one customer has boughtGET /v1/customers/{customer_id}/topups
    Two endpoints do this. listCustomerTopups takes the QuotaStack customer_id. listCustomerTopupsByExternalId takes your own ID. Only the lookup differs; the reply is the same.
  • You want the bundles you have defined, not what anyone boughtGET /v1/topup-packages
6 of 6 operations documented in full.

List topup packages

GET/v1/topup-packages
Idempotency-KeyNot used. This is a read.
EnvironmentSandbox and Live
AuthTenant API key
WritesNothing. 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.

Query parameters
FieldTypeMeaning
active_onlyoptionalboolean

Whether to hide packages you turned off.

If you leave it out: QuotaStack hides those packages. The spec says the default is false. The code says true. Only the exact word false shows every package. 0 and no both mean active only.

cursoroptionalstring

Opaque pagination cursor from the previous page's pagination.next_cursor. Omit to start at the first page.

limitoptionalinteger

Page size. Default 20, max 100.

Response 200
{
  "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
  }
}
Response fields
FieldTypeMeaning
datarequiredarray
data.namerequiredstring

What you call this package. QuotaStack shows it back to you, and never to your customer.

data.creditsrequiredinteger (int64)

How many credits one purchase of this package grants.

Unit: millicredits, where 1 credit is 1000 millicredits.

data.is_activerequiredboolean

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_secondsoptionalinteger (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 null here. Set an expiry on the grant instead, with expires_at or duration_seconds.

data.metadatarequiredobject

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.

paginationrequiredobject

Cursor-based pagination envelope. Returned on every list endpoint. No total — cursor-only.

Create a topup package

POST/v1/topup-packages
Idempotency-KeyRequired. Returns 422 without it.
EnvironmentSandbox and Live
AuthTenant API key
WritesOne 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.

Header parameters
FieldTypeMeaning
Idempotency-Keyrequiredstring

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 422, before any change is made.

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 X-Idempotent-Replayed: true. Replays are available for 24 hours.

Derive the key from the business event rather than a random value, so a retry from anywhere reuses it — for example topup:{payment_intent_id} or renewal:{subscription_id}:{period_start}.

Body parameters
FieldTypeMeaning
namerequiredstring

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 422. This field is required.

creditsrequiredinteger (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 422. This field is required, and must be more than zero.

metadataoptionalobject

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.

Request
{
  "name": "10,000 credits",
  "credits": 10000000
}
Response 201
{
  "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"
}
Response fields
FieldTypeMeaning
idrequiredstring (uuid)
tenant_idrequiredstring (uuid)
namerequiredstring

What you call this package. QuotaStack shows it back to you, and never to your customer.

creditsrequiredinteger (int64)

How many credits one purchase of this package grants.

Unit: millicredits, where 1 credit is 1000 millicredits.

is_activerequiredboolean

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_secondsoptionalinteger (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 null here. Set an expiry on the grant instead, with expires_at or duration_seconds.

metadatarequiredobject

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_atrequiredstring (date-time)
updated_atrequiredstring (date-time)

Update a topup package

PATCH/v1/topup-packages/{id}
Idempotency-KeyRequired. Returns 422 without it.
EnvironmentSandbox and Live
AuthTenant API key
WritesThe 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.

Header parameters
FieldTypeMeaning
Idempotency-Keyrequiredstring

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 422, before any change is made.

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 X-Idempotent-Replayed: true. Replays are available for 24 hours.

Derive the key from the business event rather than a random value, so a retry from anywhere reuses it — for example topup:{payment_intent_id} or renewal:{subscription_id}:{period_start}.

Path parameters
FieldTypeMeaning
idrequiredstring (uuid)
Body parameters
FieldTypeMeaning
nameoptionalstring

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_activeoptionalboolean

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.

metadataoptionalobject

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.

Request
{
  "is_active": false
}
Response 200
{
  "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"
}
Response fields
FieldTypeMeaning
idrequiredstring (uuid)
tenant_idrequiredstring (uuid)
namerequiredstring

What you call this package. QuotaStack shows it back to you, and never to your customer.

creditsrequiredinteger (int64)

How many credits one purchase of this package grants.

Unit: millicredits, where 1 credit is 1000 millicredits.

is_activerequiredboolean

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_secondsoptionalinteger (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 null here. Set an expiry on the grant instead, with expires_at or duration_seconds.

metadatarequiredobject

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_atrequiredstring (date-time)
updated_atrequiredstring (date-time)

Grant a topup

POST/v1/topups/grant
Idempotency-KeyRequired. Returns 422 without it.
EnvironmentSandbox and Live
AuthTenant API key
WritesOne 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.

Header parameters
FieldTypeMeaning
Idempotency-Keyrequiredstring

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 422, before any change is made.

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 X-Idempotent-Replayed: true. Replays are available for 24 hours.

Derive the key from the business event rather than a random value, so a retry from anywhere reuses it — for example topup:{payment_intent_id} or renewal:{subscription_id}:{period_start}.

Body parameters
FieldTypeMeaning
customer_idoptionalstring

QuotaStack's own ID for the customer who bought this.

If you leave it out: Send external_customer_id instead. Send neither and QuotaStack returns 400.

external_customer_idoptionalstring

Your own ID for the customer who bought this.

If you leave it out: Send customer_id instead. Send both and QuotaStack returns 400. An ID QuotaStack has never seen creates a new customer.

package_idoptionalstring (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 credits for you.

If you leave it out: The sale belongs to no package. Send credits yourself, the same as always.

creditsrequiredinteger (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 422. This field is required even when you send package_id.

price_paidrequiredinteger (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 currency, so 500 means 5.00 US dollars when currency is USD.

If you leave it out: QuotaStack stores 0. The credits are still granted.

currencyrequiredstring

Which currency price_paid is in. An ISO 4217 code, such as USD, EUR, or INR.

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_idoptionalstring

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_atoptionalstring (date-time)

When these credits stop counting toward the balance.

If you leave it out: QuotaStack uses the package's expires_after_seconds when the grant names a package that has one. Otherwise the credits never expire. Send this with duration_seconds and QuotaStack returns 422, naming duration_seconds.

duration_secondsoptionalinteger (int64)1–315360000

How long these credits last, counted from when the block starts.

If you leave it out: QuotaStack uses expires_at instead. Send neither and the credits never expire. Send both and QuotaStack returns 422. The error names duration_seconds, and nothing is granted.

stack_afteroptionalobject

Starts these credits right after another block ends, instead of right now. QuotaStack finds that other block by matching metadata_match. stack_after requires duration_seconds, and returns 422 without one.

If you leave it out: The credits start now, not after another block.

stack_after.metadata_matchrequiredobject

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.

stack_after.fallbackoptionalstring

What to do when no block matches metadata_match.

If you leave it out: By default, QuotaStack uses now for this.

  • nowStart the new credits right now, as if stack_after were not sent.
  • rejectQuotaStack sends back a 409 instead, and grants nothing.
priorityoptionalinteger0–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.

metadataoptionalobject

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.

Request
{
  "external_customer_id": "user_42",
  "package_id": "0192f5a4-7c31-7b8e-9a2d-4f6c8e1b3a72",
  "credits": 10000000,
  "price_paid": 1000,
  "currency": "USD",
  "external_payment_id": "pay_xyz789"
}
Response 201
{
  "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
  }
}
Response fields
FieldTypeMeaning
idrequiredstring (uuid)
tenant_idrequiredstring (uuid)
customer_idrequiredstring (uuid)
package_idoptionalstring (uuid)

Which package this purchase came from.

If you leave it out: The grant named no package. This topup is a one-off.

environmentrequiredstring
  • live
  • sandbox

Which environment this resource lives in. Determined by the API key prefix used (qs_live_… → live, qs_test_… → sandbox).

credits_grantedrequiredinteger (int64)

How many credits went to the customer.

Unit: millicredits, where 1 credit is 1000 millicredits.

price_paidrequiredinteger (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 currency, so 500 means 5.00 US dollars when currency is USD.

currencyrequiredstring

Which currency price_paid is in. An ISO 4217 code, such as USD, EUR, or INR.

external_payment_idoptionalstring

The ID you sent when you made this sale. QuotaStack keeps it word for word.

expires_atoptionalstring (date-time)

When these credits stop counting in the balance.

If you leave it out: These credits never run out.

statusrequiredstring

Where this purchase stands.

  • completedQuotaStack wrote the sale down. Every topup from this API reads this way.
  • refundedYou gave the money back. No call on this API sets this value.
metadatarequiredobject

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_atrequiredstring (date-time)
updated_atrequiredstring (date-time)

Errors

  • 404No package here has this package_id, or the package is inactive. QuotaStack sends the same reply for both, so check is_active yourself. not-found

List a customer topups

GET/v1/customers/{customer_id}/topups
Idempotency-KeyNot used. This is a read.
EnvironmentSandbox and Live
AuthTenant API key
WritesNothing. 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.

Path parameters
FieldTypeMeaning
customer_idrequiredstring

Customer identifier. Can be either a QuotaStack UUID or your own external_id — the server resolves both. For strict external-id resolution, use the /v1/customer-by-external-id/... path family instead.

Query parameters
FieldTypeMeaning
cursoroptionalstring

Opaque pagination cursor from the previous page's pagination.next_cursor. Omit to start at the first page.

limitoptionalinteger

Page size. Default 20, max 100.

Response 200
{
  "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
  }
}
Response fields
FieldTypeMeaning
datarequiredarray
data.package_idoptionalstring (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_grantedrequiredinteger (int64)

How many credits went to the customer.

Unit: millicredits, where 1 credit is 1000 millicredits.

data.price_paidrequiredinteger (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 currency, so 500 means 5.00 US dollars when currency is USD.

data.currencyrequiredstring

Which currency price_paid is in. An ISO 4217 code, such as USD, EUR, or INR.

data.external_payment_idoptionalstring

The ID you sent when you made this sale. QuotaStack keeps it word for word.

data.expires_atoptionalstring (date-time)

When these credits stop counting in the balance.

If you leave it out: These credits never run out.

data.statusrequiredstring

Where this purchase stands.

  • completedQuotaStack wrote the sale down. Every topup from this API reads this way.
  • refundedYou gave the money back. No call on this API sets this value.
data.metadatarequiredobject

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.

paginationrequiredobject

Cursor-based pagination envelope. Returned on every list endpoint. No total — cursor-only.

List a customer topups by external ID

GET/v1/customer-by-external-id/{external_id}/topups
Idempotency-KeyNot used. This is a read.
EnvironmentSandbox and Live
AuthTenant API key
WritesNothing. 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.

Path parameters
FieldTypeMeaning
external_idrequiredstring

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.

Query parameters
FieldTypeMeaning
cursoroptionalstring

Opaque pagination cursor from the previous page's pagination.next_cursor. Omit to start at the first page.

limitoptionalinteger

Page size. Default 20, max 100.

Response 200
{
  "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
  }
}
Response fields
FieldTypeMeaning
datarequiredarray
data.package_idoptionalstring (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_grantedrequiredinteger (int64)

How many credits went to the customer.

Unit: millicredits, where 1 credit is 1000 millicredits.

data.price_paidrequiredinteger (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 currency, so 500 means 5.00 US dollars when currency is USD.

data.currencyrequiredstring

Which currency price_paid is in. An ISO 4217 code, such as USD, EUR, or INR.

data.external_payment_idoptionalstring

The ID you sent when you made this sale. QuotaStack keeps it word for word.

data.expires_atoptionalstring (date-time)

When these credits stop counting in the balance.

If you leave it out: These credits never run out.

data.statusrequiredstring

Where this purchase stands.

  • completedQuotaStack wrote the sale down. Every topup from this API reads this way.
  • refundedYou gave the money back. No call on this API sets this value.
data.metadatarequiredobject

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.

paginationrequiredobject

Cursor-based pagination envelope. Returned on every list endpoint. No total — cursor-only.