docs/plans/5.5-admin-customers-api.md
Status: Shipped in 5.5 (CustomersController + nested addresses, credit_cards, store_credits; CustomerGroupsController)
Target: Spree 5.5
Depends on: 6.0-admin-api.md, 6.0-platform-auth.md, 5.4-6.0-eu-legal-compliance.md
Author: damian
Last updated: 2026-05-20
Ship the /api/v3/admin/customers/* endpoint family in 5.5 — list, retrieve, create, update, destroy plus nested addresses and store credits. The model under the hood is still Spree::User (renamed to Spree::Customer in 6.0 per 6.0-platform-auth.md); the URL, prefix, and serializer names are already "customer" so the 6.0 rename is a model swap with no API surface churn. This is the merchant-facing version of the Store API customer endpoints — same record, different visibility, full CRUD scoped to the current store.
Competitor study: Shopify (REST + GraphQL), Medusa v2, Vendure, Saleor. We borrow Shopify's tags + marketing-consent timestamp shape, Medusa's address sub-resource pattern, and Saleor's metadata split. Customer groups, tags, segments, and GDPR endpoints are deferred to follow-up plans.
cus_ in 5.5, becomes cust_ in 6.0. Spree::UserMethods already declares has_prefix_id :cus. The 6.0 rename to cust_ happens with the model rename — both prefixes must resolve through the SDK type during the transition. Do NOT introduce cust_ early./customers from day one. Matches existing Store API URL (/api/v3/store/customers, /api/v3/store/customer). Hides the Spree::User legacy table name from API consumers.current_store OR linked via Spree::CustomerGroupUser (when groups land) OR with a store_id-tagged role. Never returns admin users. Never returns users from other stores.POST /customers/:id/addresses per Medusa's pattern. Default billing/shipping is a flag on the address (is_default_billing, is_default_shipping), not a separate FK write target. Setting one rotates the previous default in the same transaction.default_billing_address_id and default_shipping_address_id are read-only on the customer payload. Writes happen by PATCH-ing the address with is_default_billing: true. Aligns with how Spree::UserAddress already works.?q[search]= Ransack scope plus per-field _cont/_eq. Spree::User.search already searches email + first_name + last_name. We expose it via Ransack scope plus structured filters so merchants can do both "type a name" and "filter by tag list".?expand=orders. orders_count, total_spent, last_order_completed_at are computed (cacheable) attributes on the serializer — same pattern as Shopify's numberOfOrders / amountSpent / lastOrder. Full list comes from ?expand=orders or the existing GET /api/v3/admin/orders?q[user_id_eq]=cus_xxx.acts_as_taggable_on :tags is already on Spree::User (line 38 of user_methods.rb). Exposing it in the admin payload is free. Customer groups and segments do not ship in 5.5.5.4-6.0-eu-legal-compliance.md. Both accepts_email_marketing (boolean) and email_marketing_consent_updated_at are returned. Setting the boolean updates the timestamp (already wired in that plan)./anonymize, /export) are out of scope for 5.5. They land in Phase 2 of the EU compliance plan. The plan reserves the URLs in 6.0-admin-api.md lines 508-509; we do not implement them here.6.0-admin-api.md "Open Questions" — bulk envelope is unresolved. Merchants who need bulk operations issue a loop client-side.Spree::Api::V3::CustomerSerializer (Store) is the customer self-view. Spree::Api::V3::Admin::CustomerSerializer (Admin) extends it with merchant-only fields (login, sign-in audit, internal_note, tags, computed order stats, timestamps). Never duplicate Store fields.| Capability | Shopify | Medusa v2 | Vendure | Saleor | Spree 5.5 |
|---|---|---|---|---|---|
List /customers | ✅ | ✅ | ✅ (customers query) | ✅ (customers query) | ✅ |
| Search syntax | query=tag:VIP email:foo | q= plus structured $and/$or | CustomerFilterParameter with contains/eq | where + search | Ransack ?q[search]= + q[email_cont] |
| Addresses sub-resource | Embedded array | ✅ separate endpoints | createCustomerAddress mutation | addressCreate mutation | ✅ separate endpoints (Medusa-style) |
| Default billing/shipping | One default_address (single) | is_default_shipping/is_default_billing flags | defaultShippingAddress/defaultBillingAddress | Same as Vendure | Two flags on address (Medusa-style, matches existing Spree split) |
| Tags | ✅ | ❌ (uses groups) | ❌ (uses groups) | ❌ (uses groups) | ✅ (already on model) |
| Customer groups | ❌ (uses tags + segments) | ✅ | ✅ | ✅ (permission groups) | Deferred (table exists, API later) |
| Segments | ✅ (segment query language) | ❌ | ❌ | ❌ | Deferred |
| Marketing consent | email_marketing_consent object with state + timestamp + opt-in level + source | ❌ (custom only) | ❌ (custom only) | ❌ (custom only) | accepts_email_marketing + email_marketing_consent_updated_at (boolean + timestamp; opt-in level + source deferred) |
| Order count + spend | numberOfOrders, amountSpent | Via expand | Via orders connection | Via orders connection | orders_count + total_spent + last_order_completed_at (computed) |
| Order history endpoint | /customers/:id/orders | Via expand | Via expand | Via expand | ?expand=orders + existing /orders?q[user_id_eq]= |
| Internal note | ✅ (note) | ❌ | ❌ | ✅ (note, requires permission) | ✅ (internal_note rich text — already on model) |
| Tax exempt | ✅ | ❌ | ❌ | ❌ | Deferred (B2B plan owns it) |
| Account state | enabled/disabled/invited | has_account (registered vs guest) | n/a (Customer always has User) | isActive | has_account (mirror Medusa: present if encrypted_password.present?) |
| Anonymize / export | Privacy API (deletion request, data request) | ❌ | ❌ | ❌ | Deferred to Phase 2 (EU compliance plan) |
| Send invite / activation URL | ✅ | ❌ | ❌ | redirectUrl on create | Deferred (uses existing password-reset email flow) |
| Bulk delete / update | ✅ (REST has none, GraphQL has bulk operations) | ❌ | ❌ | customerBulkDelete | Deferred |
| Avatar / image | ✅ (image) | ❌ | ❌ | ✅ (avatar) | ✅ (avatar — already on model) |
| Public/private metadata split | Metafields | metadata | customFields | metadata + privateMetadata | public_metadata + private_metadata (already on model) |
Where competitors disagree, we recommend:
default_address is too restrictive — most merchants want different default ship vs bill (especially B2B). Medusa/Vendure/Saleor split. Spree already has bill_address_id + ship_address_id on the user. Adopt the split. Use Medusa-style flag-based PATCH semantics so writes are address-local instead of customer-local.tag:VIP email:foo query string is great for end users but clashes with our Ransack convention. Medusa's $and/$or JSON is overkill for the URL. Use Ransack throughout (?q[search]=, ?q[email_cont]=, ?q[tags_name_in][]=VIP). Document a "common queries" cookbook in the OpenAPI spec.customer/:id/stats). Acceptable staleness for an admin dashboard.Store API base (Spree::Api::V3::CustomerSerializer — already exists, no changes):
id, email, first_name, last_name, phone,
accepts_email_marketing, email_marketing_consent_updated_at, # ← add timestamp (Phase 1 EU plan)
available_store_credit_total, display_available_store_credit_total,
default_billing_address (one), default_shipping_address (one),
addresses (many)
Admin extension (Spree::Api::V3::Admin::CustomerSerializer — extend the existing file):
| Field | Source | Rationale |
|---|---|---|
login | user.login | Distinct from email if customer username is supported |
created_at, updated_at | column | Audit |
last_sign_in_at, current_sign_in_at, sign_in_count | columns | Already in current admin serializer |
last_sign_in_ip, current_sign_in_ip | columns | Already in current admin serializer |
failed_attempts | column | Already in current admin serializer (Devise; lockout in 6.0 platform-auth plan) |
tags | tag_list | Merchant-tagging, not facing storefront |
internal_note_html | internal_note.body.to_s | Sanitised HTML; matches description_html pattern in 6.0-rich-text-descriptions.md |
metadata | user.metadata (Stripe-style; backed by private_metadata column) | Admin-only key/value bag. Match the Order/LineItem/Refund convention; never expose public_metadata. |
orders_count | SELECT COUNT(*) FROM orders WHERE user_id=? (cached) | Computed |
total_spent | SELECT SUM(total) FROM orders WHERE user_id=? AND state='complete' (cached, returned as string per Alba :oj_rails) | Computed |
display_total_spent | Spree::Money.new(total_spent, currency).to_s | Human format |
last_order_completed_at | MAX(completed_at) WHERE state='complete' | Drives "last seen" admin column |
default_billing_address_id | bill_address&.prefixed_id | Read-only convenience |
default_shipping_address_id | ship_address&.prefixed_id | Read-only convenience |
default_billing_address | one (admin address serializer) | ?expand=default_billing_address only |
default_shipping_address | one | ?expand=default_shipping_address only |
addresses | many | ?expand=addresses only — full address book |
orders | many | ?expand=orders only — paginated externally; this returns up to last 25, embedded |
store_credits | many | ?expand=store_credits only |
Omit from admin payload:
available_store_credit_total — store-aware aggregate that requires a currency context. Keep on Store API for the customer's own view; admins use the nested /store_credits endpoint instead (which gives full per-credit breakdown).password_digest / encrypted_password / password_salt / reset_password_token — never expose.confirmation_token, unlock_token — never expose.avatar_url — defer until the SPA needs it (Customer detail page may surface it via ?expand=avatar returning an ActiveStorage signed URL — track in 6.0 SPA plan, not here).6.0-admin-api.md lines 502-525)GET /api/v3/admin/customersList with Ransack + sort + paginate.
GET /api/v3/admin/customers?q[search]=jane&q[tags_name_in][]=VIP&sort=-last_order_completed_at&per_page=50
{
"data": [
{
"id": "cus_5kJ3pRq8",
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"phone": "+15551234567",
"has_account": true,
"tags": ["VIP", "newsletter-2024"],
"accepts_email_marketing": true,
"email_marketing_consent_updated_at": "2026-02-11T09:14:22Z",
"orders_count": 12,
"total_spent": "1450.00",
"display_total_spent": "$1,450.00",
"last_order_completed_at": "2026-04-12T18:21:08Z",
"default_billing_address_id": "addr_aB1c2D3e",
"default_shipping_address_id": "addr_aB1c2D3e",
"created_at": "2024-08-01T12:00:00Z",
"updated_at": "2026-04-12T18:21:09Z"
}
],
"meta": { "total_count": 1, "page": 1, "pages": 1, "limit": 50 }
}
Ransack filters exposed (whitelist additions on Spree::User):
| Filter | Example | Maps to |
|---|---|---|
q[search] | ?q[search]=jane | Spree::User.search scope (email + first/last name) |
q[email_cont] | ?q[email_cont][email protected] | substring email |
q[email_eq] | exact email | |
q[first_name_cont], q[last_name_cont] | substring | |
q[phone_cont] | ||
q[accepts_email_marketing_eq] | =true | newsletter only |
q[tags_name_in][] | repeat per tag | inner-join acts_as_taggable_on |
q[created_at_gteq], q[created_at_lteq] | ISO datetime | range |
q[orders_completed_at_gteq] | last-active range — joins orders | already supported via whitelisted_ransackable_associations adding orders |
q[orders_total_gteq] | total spend filter (joins orders, sums via Ransack scope) | Ransack scope with_min_total_spent (new — server-side aggregate) |
sort=-last_order_completed_at — JSON:API style. Supported sorts: created_at, email, last_sign_in_at, orders_count, total_spent, last_order_completed_at.
GET /api/v3/admin/customers/:idGET /api/v3/admin/customers/cus_5kJ3pRq8?expand=addresses,orders,store_credits
Returns the full admin payload plus addresses, orders (last 25 complete), store_credits arrays.
POST /api/v3/admin/customers{
"email": "[email protected]",
"first_name": "Sam",
"last_name": "Johnson",
"phone": "+15555550199",
"accepts_email_marketing": false,
"tags": ["wholesale"],
"internal_note_html": "<p>Met at trade show.</p>",
"public_metadata": { "referral": "tradeshow-2026" }
}
201 Created → returns the new customer payload. No password is set. A welcome / password-reset email is NOT sent automatically — that's a separate explicit action (deferred; aligns with redirectUrl semantics from Saleor, but we keep it out of 5.5 to avoid a default-on email). Merchants who want one POST to /auth/password_resets after creation.
PATCH /api/v3/admin/customers/:idSame body shape as create, all fields optional. Setting accepts_email_marketing toggles the timestamp via the model callback (per EU compliance plan).
DELETE /api/v3/admin/customers/:idHard delete only if customer.can_be_deleted? (no completed orders) — already enforced by Spree::UserMethods#check_completed_orders. Returns 204 on success, 422 with code customer_has_orders otherwise. Anonymisation is the right path for customers with order history; deferred to Phase 2.
GET /api/v3/admin/customers/:customer_id/addresses{
"data": [
{
"id": "addr_aB1c2D3e",
"label": "Home",
"first_name": "Jane",
"last_name": "Doe",
"address1": "123 Main St",
"address2": null,
"city": "Brooklyn",
"postal_code": "11201",
"phone": "+15551234567",
"company": null,
"state_name": "New York",
"state_abbr": "NY",
"country_iso": "US",
"country_name": "United States",
"is_default_billing": true,
"is_default_shipping": true,
"metadata": null,
"customer_id": "cus_5kJ3pRq8",
"created_at": "2024-08-01T12:00:00Z",
"updated_at": "2026-04-12T18:21:08Z"
}
],
"meta": { "total_count": 1, "page": 1, "pages": 1, "limit": 50 }
}
POST /api/v3/admin/customers/:customer_id/addresses{
"label": "Office",
"first_name": "Jane",
"last_name": "Doe",
"address1": "456 5th Ave",
"city": "New York",
"postal_code": "10001",
"country_iso": "US",
"state_abbr": "NY",
"phone": "+15551234567",
"is_default_shipping": true
}
201 Created. If is_default_shipping: true, the previous default ship address loses the flag in the same transaction. Same rule for billing. Validation runs on write — incomplete addresses (missing required country/state per country rules) return 422.
PATCH /api/v3/admin/customers/:customer_id/addresses/:idToggle defaults via { "is_default_billing": true }. Soft-deletes (paranoia on Spree::Address) are honoured.
DELETE /api/v3/admin/customers/:customer_id/addresses/:idSoft-delete via paranoia. If it was a default, the customer loses that default (no auto-promotion of another address — merchant decides next).
GET /api/v3/admin/customers/:customer_id/store_creditsReturns full per-credit breakdown (amount, amount_used, amount_remaining, currency, category, created_by, memo). Existing Spree::Api::V3::Admin::StoreCreditSerializer covers it.
POST /api/v3/admin/customers/:customer_id/store_credits{
"amount": "50.00",
"currency": "USD",
"category_id": "scc_xxx",
"memo": "Goodwill credit"
}
created_by_id is set automatically from the authenticated admin.
PATCH /api/v3/admin/customers/:customer_id/store_credits/:idUpdate memo, category, amount (only if amount_used == 0).
DELETE /api/v3/admin/customers/:customer_id/store_credits/:idAllowed only if amount_used == 0. Otherwise 422 store_credit_in_use.
Spree::User has bill_address_id + ship_address_id (singletons) AND Spree::UserAddress join (the address book — multiple addresses per user). The existing Spree::Address serializer already exposes is_default_billing / is_default_shipping flags computed from the user's bill/ship FKs.
Three rules:
is_default_billing = (address.id == address.user.bill_address_id). Already implemented.is_default_billing: true updates the user's bill_address_id in a transaction and clears the flag from any other address. Implementation: a controller-level service Spree::Customers::SetDefaultAddress.call(customer, address, kind:). No new model concern needed.details shape as the rest of the admin API.From competitor parity + research:
?q[search]=jane. Single param, full-text-ish.?q[tags_name_in][]=VIP&q[tags_name_in][]=wholesale. AND between facets, OR within (Ransack default for _in).?q[orders_completed_at_gteq]=2026-01-01 (active in 2026). ?sort=-last_order_completed_at.?q[orders_total_gteq]=1000 (lifetime spend ≥ $1000). New Ransack scope, joins + sums orders.?q[accepts_email_marketing_eq]=true.?q[created_at_gteq]=...&q[created_at_lteq]=....We do not implement a Shopify-style query=tag:VIP parser. Ransack is the convention; the SDK can build query strings from a typed filter object on the client side.
| Today (5.5) | 6.0 |
|---|---|
Spree::User | Spree::Customer |
cus_xxx prefix | cust_xxx prefix |
Spree.user_class | Spree.customer_class |
bill_address_id, ship_address_id columns | same column names — no migration |
internal_note (ActionText) | internal_note_html field stored as text per 6.0-rich-text-descriptions.md |
accepts_email_marketing | unchanged |
URL /customers | unchanged |
customer_id field on Address admin payload | unchanged (already customer_id, not user_id, in current admin address serializer) |
ransackable_attributes includes id email first_name last_name accepts_email_marketing | extended with phone, created_at, last_sign_in_at |
No customer_groups field | adds customer_group_ids array (groups plan ships post-5.5) |
No failed_attempts lockout admin | platform-auth lockout adds locked_at field |
Field renames triggered by other plans:
firstname → first_name, lastname → last_name, zipcode → postal_code are renames on Spree::Address, owned by 5.4-store-api-naming-standardization.md. Customer addresses use the renamed fields.metafield → custom_field rename (5.4-6.0-custom-fields-rename.md) — the public_metadata / private_metadata JSON columns are not affected; only the polymorphic Metafield records are. No customer payload change.SDK consumers must: treat the prefix as opaque. Type definitions in @spree/admin-sdk use a branded string (type CustomerId = string & { __brand: 'CustomerId' }), so the cus_ → cust_ swap in 6.0 is invisible at the type level.
These are reserved in 6.0-admin-api.md and the EU compliance plan; do not implement them in 5.5:
POST /api/v3/admin/customers/:id/anonymize (Phase 2 GDPR — 5.4-6.0-eu-legal-compliance.md §1.9)GET /api/v3/admin/customers/:id/export (Phase 2 GDPR — same plan §1.8)POST /api/v3/admin/customers/:id/send_invite (no spec, no design — defer)POST /api/v3/admin/customers/bulk / bulk_update / bulk_destroy (per 6.0-admin-api.md Open Questions)/api/v3/admin/customer_groups/* and /customer_groups/:id/members (already listed in 6.0-admin-api.md lines 535-545; ship after 5.5 in their own PR)GET /api/v3/admin/newsletter_subscribers (6.0-admin-api.md lines 548-549; separate)accepts_email_marketing boolean is enough for MVP)6.0-channels-catalogs-b2b.md owns it via Company)This is a new endpoint family. No backfills, no data migration.
Spree::User whitelisted ransack attributes with phone, created_at, last_sign_in_at, tags (already includes the rest). Add scope with_min_total_spent. One PR.Spree::Api::V3::Admin::CustomerSerializer with the new fields above. Run typelizer + zod regeneration per CLAUDE.md pipeline.Spree::Api::V3::Admin::CustomersController inheriting from ResourceController. Override model_class, serializer_class, scope (filter to current_store via order or role join), permitted_params (use Spree::PermittedAttributes.user_attributes).Spree::Api::V3::Admin::Customers::AddressesController (/customers/:customer_id/addresses). Uses existing Spree::PermittedAttributes.address_attributes. Implements default-flag rotation in a service Spree::Customers::SetDefaultAddress.Spree::Api::V3::Admin::Customers::StoreCreditsController (/customers/:customer_id/store_credits). Existing serializer is reusable.customer/:id/stats with 5-minute TTL for the computed orders_count, total_spent, last_order_completed_at triple. Bust on order.completed, order.canceled, order.refunded events via subscriber.@spree/admin-sdk: client.customers.list/retrieve/create/update/destroy, client.customers.addresses.{list,create,update,destroy}, client.customers.storeCredits.{list,retrieve,create,update,destroy}.6.0-admin-api.md lines 503-507, 514-517, 520-524 as endpoints ship.Spree.user_class in controller code; do not hardcode Spree::User or Spree::Customer. The 6.0 rename swaps the constant.encrypted_password, password_salt, reset_password_token, confirmation_token, unlock_token in any serializer.bill_address_id / ship_address_id directly from a customer-payload PATCH. Always go through the address PATCH path. This keeps the audit cleaner and matches how the storefront writes default addresses.iso8601 Alba directive.total_spent is a string, per Alba :oj_rails BigDecimal-as-string convention.accessible_by(current_ability, :show) before any other scoping — admin role gating is upstream of store filtering.Spree.user_class.for_store(store) returning self).internal_note_html direction → Yes — accept ActionText storage, expose internal_note_html (sanitised) as the API contract. Matches products' description_html.tags: [...] in PATCH replaces the full set.docs/plans/6.0-admin-api.md — endpoint placeholders this plan fills in (lines 502-525)docs/plans/6.0-platform-auth.md — Spree.user_class → Spree.customer_class, cus_ → cust_ prefixdocs/plans/5.4-6.0-eu-legal-compliance.md — marketing-consent timestamp, deferred anonymise/export endpointsdocs/plans/5.4-store-api-naming-standardization.md — address field renames already appliedspree/api/app/serializers/spree/api/v3/customer_serializer.rb — Store API base (read-only reference)spree/api/app/serializers/spree/api/v3/admin/customer_serializer.rb — current admin serializer to extendspree/api/app/serializers/spree/api/v3/admin/address_serializer.rb — sub-resource serializer (already exists)spree/api/app/serializers/spree/api/v3/admin/store_credit_serializer.rb — sub-resource serializer (already exists)spree/core/app/models/concerns/spree/user_methods.rb — model concern, ransack whitelist, has_prefix_id :cusspree/core/lib/spree/permitted_attributes.rb lines 300-303 — user_attributes allowlist (reuse as-is)