Customer Identification
Two identifier types and two URL forms for customers — when to use each, and how to avoid mixing them.
external_customer_id) and the one QuotaStack generates (customer_id UUID). Use either. Most apps only ever need the one they already have. Quick take
- Two ID types: your tenant ID (
external_customer_id) or the QuotaStack UUID (customer_id) - Two URL families:
/v1/customers/{id}/...vs/v1/customer-by-external-id/{ext_id}/... - Body endpoints accept one of the two fields — never both, never neither
- URL-encode special chars (
:,/, spaces) when using external IDs in URL paths
Customer Identification
There are two ways to identify a customer in QuotaStack. Use whichever is convenient. Never mix them in one request.
Two identifier types
| Type | Field name | URL prefix | Owner |
|---|---|---|---|
| QuotaStack UUID | customer_id | /v1/customers/{id} | QuotaStack |
| Your tenant ID | external_customer_id | /v1/customer-by-external-id/{external_id} | You |
external_customer_id is whatever your system already calls the user — an email, a database row ID, a compound key like userId:companionId. QuotaStack stores it verbatim and you can query by it directly.
Most tenants never need the UUID. They supply external_customer_id everywhere and pretend QuotaStack’s UUID doesn’t exist. That’s fine — both forms are fully supported.
Body endpoints
Four endpoints accept either field in the request body. Exactly one required (both or neither returns 400):
POST /v1/usage
POST /v1/usage/batch
POST /v1/reservations
POST /v1/topups/grant
Recommended — use your own ID:
{
"external_customer_id": "userId:companionId",
"credits": 50000,
"currency": "INR"
}
Alternative — use the QuotaStack UUID:
{
"customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
"credits": 50000,
"currency": "INR"
}
For POST /v1/usage/batch, each event in the array picks its own field independently. You can mix them across events, just not within a single event.
URL endpoints
Every customer-scoped path has two forms — pick one based on which ID type you have:
| Operation | UUID form | External-ID form |
|---|---|---|
| Get customer | GET /v1/customers/{id} | GET /v1/customer-by-external-id/{external_id} |
| Update customer | PATCH /v1/customers/{id} | PATCH /v1/customer-by-external-id/{external_id} |
| Delete customer | DELETE /v1/customers/{id} | DELETE /v1/customer-by-external-id/{external_id} |
| Get balance | GET /v1/customers/{id}/credits | GET /v1/customer-by-external-id/{external_id}/credits |
| Manual grant | POST /v1/customers/{id}/credits/grant | POST /v1/customer-by-external-id/{external_id}/credits/grant |
| Adjust credits | POST /v1/customers/{id}/credits/adjust | POST /v1/customer-by-external-id/{external_id}/credits/adjust |
| Ledger history | GET /v1/customers/{id}/credits/history | GET /v1/customer-by-external-id/{external_id}/credits/history |
| Entitlement check | GET /v1/customers/{id}/entitlements/{key} | GET /v1/customer-by-external-id/{external_id}/entitlements/{key} |
| List topups | GET /v1/customers/{id}/topups | GET /v1/customer-by-external-id/{external_id}/topups |
The external-id family uses singular customer-by-external-id (no s). Don’t confuse this with the plural /customers/ path.
URL-encoding external IDs
If your external_id contains special characters (:, /, spaces), URL-encode the entire segment:
| Character | Encoded |
|---|---|
: | %3A |
/ | %2F |
| %20 |
Example for a compound ID like userId:companionId:
PATCH /v1/customer-by-external-id/userId%3AcompanionId
{
"display_name": "Siddharth × Kabir"
}
Auto-creation
When you reference a customer that QuotaStack hasn’t seen before, certain write endpoints auto-create the customer record on the fly. Read endpoints return 404 for unknown customers.
Auto-create:
POST /v1/usagePOST /v1/usage/batchPOST /v1/reservationsPOST /v1/topups/grantPOST /v1/customers/{id}/credits/grantPOST /v1/customer-by-external-id/{ext_id}/credits/grant
Return 404 if not found (no auto-create):
- All
GETendpoints (/credits,/credits/history,/entitlements/{metric},/topups) PATCH/DELETEon the customer resource- All reads under
/v1/customer-by-external-id/...
Auto-created customers start with display_name: null — fill it in via PATCH when you have a meaningful label. To create a customer explicitly (without a side-effect write), use POST /v1/customers.
The display_name field
Every customer can carry an optional display_name — a tenant-defined human label (max 200 chars). QuotaStack uses it for friendly display and search in the admin dashboard. Auto-created customers start with display_name: null — the dashboard falls back to rendering the external ID until you set it.
Backfill existing customers via a single PATCH:
curl -X PATCH https://api.quotastack.io/v1/customer-by-external-id/user_abc \
-H "X-API-Key: qs_live_..." \
-H "Content-Type: application/json" \
-d '{
"display_name": "Alice Nakamura"
}'
display_name is not used in billing logic — it’s purely a label for humans viewing the dashboard.
Which form to pick
Use external_customer_id (recommended for most code paths) when:
- You’re writing your own business logic and already have your user’s identifier
- You don’t want to store QuotaStack’s UUID in your database
- You want API calls to read naturally in your code (
user_abcis more meaningful than an opaque UUID)
Use customer_id (the UUID) when:
- Receiving webhooks — payloads echo the QuotaStack UUID as the canonical ID
- You’ve already fetched a customer and have the UUID in hand from a prior response
- You’re integrating with QuotaStack-admin tooling that returns UUIDs
Deleting a customer
DELETE /v1/customers/{id} (and the external-id form) marks the customer as deleted for listing and dashboard purposes only. It is a soft delete — the record is retained with a deleted_at timestamp so history stays intact.
Important: DELETE does not cascade. After deletion:
- Credit accounts stay. Existing balances are not zeroed.
- Active reservations stay. They run until TTL or until you commit/release them.
- Active subscriptions stay. Scheduled renewal grants still post credits into the soft-deleted customer’s account.
- Ledger history is retained in full.
To fully wind down a customer, cancel their subscription (cancel_immediately: true), release any outstanding reservations, and void remaining credits via POST /v1/customers/{id}/credits/adjust with a negative delta equal to the balance. Then call DELETE to hide the customer from list views. Future releases may add cascade semantics — don’t rely on DELETE alone for compliance purposes today.
Errors
| Status | Cause | Fix |
|---|---|---|
400 | Both customer_id and external_customer_id provided in a body | Remove one |
400 | Neither provided | Add one |
404 | External ID does not match any customer | Verify the ID, or use an auto-create endpoint (/usage, /topup/grant auto-create) |
422 | Missing required header (e.g. Idempotency-Key) | Add the header |
Related concepts
- Credits — the balance records keyed by customer
- Topups and Wallets — where
external_customer_idis most commonly used - Entitlements — checks scoped to a customer
- Idempotency — deterministic keys usually include the external ID
Common mistakes
Don't send both customer_id and external_customer_id in one request body
QuotaStack returns 400. Pick the one that matches what you have. Your code should choose based on what identifier is at hand, not include both defensively.
Don't forget to URL-encode external_id in URL paths
If your external ID contains :, /, or spaces, the URL will misparse. userId:companionId must become userId%3AcompanionId.
Don't store QuotaStack UUIDs in your database unless you need to
Most apps only need their own IDs. Storing the QuotaStack UUID adds a sync burden and a migration risk. The external_customer_id path is a first-class citizen.
Loading…