Customer Identification
Two identifier types and two URL forms for customers — when to use each, and how to avoid mixing them.
Customers have two names: the one you already use (external_customer_id) and the one QuotaStack generates (customer_id UUID). Use either. Most apps only ever need the one they already have.
external_customer_id) or the QuotaStack UUID (customer_id)/v1/customers/{id}/... vs /v1/customer-by-external-id/{ext_id}/...:, /, spaces) when using external IDs in URL pathsCustomer 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/reserve
POST /v1/topup/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/reservePOST /v1/topup/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
The mistakes developers typically make with this concept — and what to do instead.
customer_id and external_customer_id in one request bodyexternal_id in URL paths:, /, or spaces, the URL will misparse. userId:companionId must become userId%3AcompanionId.external_customer_id path is a first-class citizen.