Docs / Concepts / Customer Identification

Customer Identification

Two identifier types and two URL forms for customers — when to use each, and how to avoid mixing them.

Mental Model

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.

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

TypeField nameURL prefixOwner
QuotaStack UUIDcustomer_id/v1/customers/{id}QuotaStack
Your tenant IDexternal_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:

OperationUUID formExternal-ID form
Get customerGET /v1/customers/{id}GET /v1/customer-by-external-id/{external_id}
Update customerPATCH /v1/customers/{id}PATCH /v1/customer-by-external-id/{external_id}
Delete customerDELETE /v1/customers/{id}DELETE /v1/customer-by-external-id/{external_id}
Get balanceGET /v1/customers/{id}/creditsGET /v1/customer-by-external-id/{external_id}/credits
Manual grantPOST /v1/customers/{id}/credits/grantPOST /v1/customer-by-external-id/{external_id}/credits/grant
Adjust creditsPOST /v1/customers/{id}/credits/adjustPOST /v1/customer-by-external-id/{external_id}/credits/adjust
Ledger historyGET /v1/customers/{id}/credits/historyGET /v1/customer-by-external-id/{external_id}/credits/history
Entitlement checkGET /v1/customers/{id}/entitlements/{key}GET /v1/customer-by-external-id/{external_id}/entitlements/{key}
List topupsGET /v1/customers/{id}/topupsGET /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:

CharacterEncoded
:%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/usage
  • POST /v1/usage/batch
  • POST /v1/reserve
  • POST /v1/topup/grant
  • POST /v1/customers/{id}/credits/grant
  • POST /v1/customer-by-external-id/{ext_id}/credits/grant

Return 404 if not found (no auto-create):

  • All GET endpoints (/credits, /credits/history, /entitlements/{metric}, /topups)
  • PATCH / DELETE on 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_abc is 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

StatusCauseFix
400Both customer_id and external_customer_id provided in a bodyRemove one
400Neither providedAdd one
404External ID does not match any customerVerify the ID, or use an auto-create endpoint (/usage, /topup/grant auto-create)
422Missing required header (e.g. Idempotency-Key)Add the header

Common Mistakes

The mistakes developers typically make with this concept — and what to do instead.

×
Don't send both customer_id and external_customer_id in one request body
Why
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
Why
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
Why
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.
🤖
Building with an AI agent?
Get this page as markdown: /docs/concepts/customer-identification.md · Full index: /llms.txt