Back to Spree

Admin Customers API

docs/plans/5.5-admin-customers-api.md

5.5.026.1 KB
Original Source

Admin Customers API

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

Summary

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.

Key Decisions (do not deviate without discussion)

  1. Prefix is 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.
  2. URL is /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.
  3. CRUD is admin-scoped via the per-store join. The list endpoint filters to users with at least one order in 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.
  4. Addresses are a sub-resource, not nested attributes. 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.
  5. 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.
  6. Search is one combined ?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".
  7. Order history surfaces via three derived fields plus ?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.
  8. Tags ship in 5.5. 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.
  9. Marketing consent surfaces the timestamp from 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).
  10. GDPR endpoints (/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.
  11. No bulk endpoints in 5.5. Per 6.0-admin-api.md "Open Questions" — bulk envelope is unresolved. Merchants who need bulk operations issue a loop client-side.
  12. Two API surfaces, one record. 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.

Competitor Comparison

CapabilityShopifyMedusa v2VendureSaleorSpree 5.5
List /customers✅ (customers query)✅ (customers query)
Search syntaxquery=tag:VIP email:fooq= plus structured $and/$orCustomerFilterParameter with contains/eqwhere + searchRansack ?q[search]= + q[email_cont]
Addresses sub-resourceEmbedded array✅ separate endpointscreateCustomerAddress mutationaddressCreate mutation✅ separate endpoints (Medusa-style)
Default billing/shippingOne default_address (single)is_default_shipping/is_default_billing flagsdefaultShippingAddress/defaultBillingAddressSame as VendureTwo 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 consentemail_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 + spendnumberOfOrders, amountSpentVia expandVia orders connectionVia orders connectionorders_count + total_spent + last_order_completed_at (computed)
Order history endpoint/customers/:id/ordersVia expandVia expandVia expand?expand=orders + existing /orders?q[user_id_eq]=
Internal note✅ (note)✅ (note, requires permission)✅ (internal_note rich text — already on model)
Tax exemptDeferred (B2B plan owns it)
Account stateenabled/disabled/invitedhas_account (registered vs guest)n/a (Customer always has User)isActivehas_account (mirror Medusa: present if encrypted_password.present?)
Anonymize / exportPrivacy API (deletion request, data request)Deferred to Phase 2 (EU compliance plan)
Send invite / activation URLredirectUrl on createDeferred (uses existing password-reset email flow)
Bulk delete / update✅ (REST has none, GraphQL has bulk operations)customerBulkDeleteDeferred
Avatar / image✅ (image)✅ (avatar)✅ (avatar — already on model)
Public/private metadata splitMetafieldsmetadatacustomFieldsmetadata + privateMetadatapublic_metadata + private_metadata (already on model)

Where competitors disagree, we recommend:

  • Default address: Shopify's single 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.
  • Search ergonomics: Shopify's 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.
  • Order count / total spent: Computing on every request will N+1. Shopify and Vendure both lazy-compute (sometimes async-stale). Compute via SQL aggregation lazily, cache for 5 minutes per customer key (customer/:id/stats). Acceptable staleness for an admin dashboard.

Design Details

CustomerSerializer fields

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):

FieldSourceRationale
loginuser.loginDistinct from email if customer username is supported
created_at, updated_atcolumnAudit
last_sign_in_at, current_sign_in_at, sign_in_countcolumnsAlready in current admin serializer
last_sign_in_ip, current_sign_in_ipcolumnsAlready in current admin serializer
failed_attemptscolumnAlready in current admin serializer (Devise; lockout in 6.0 platform-auth plan)
tagstag_listMerchant-tagging, not facing storefront
internal_note_htmlinternal_note.body.to_sSanitised HTML; matches description_html pattern in 6.0-rich-text-descriptions.md
metadatauser.metadata (Stripe-style; backed by private_metadata column)Admin-only key/value bag. Match the Order/LineItem/Refund convention; never expose public_metadata.
orders_countSELECT COUNT(*) FROM orders WHERE user_id=? (cached)Computed
total_spentSELECT SUM(total) FROM orders WHERE user_id=? AND state='complete' (cached, returned as string per Alba :oj_rails)Computed
display_total_spentSpree::Money.new(total_spent, currency).to_sHuman format
last_order_completed_atMAX(completed_at) WHERE state='complete'Drives "last seen" admin column
default_billing_address_idbill_address&.prefixed_idRead-only convenience
default_shipping_address_idship_address&.prefixed_idRead-only convenience
default_billing_addressone (admin address serializer)?expand=default_billing_address only
default_shipping_addressone?expand=default_shipping_address only
addressesmany?expand=addresses only — full address book
ordersmany?expand=orders only — paginated externally; this returns up to last 25, embedded
store_creditsmany?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).

Endpoint list (extends 6.0-admin-api.md lines 502-525)

GET /api/v3/admin/customers

List with Ransack + sort + paginate.

http
GET /api/v3/admin/customers?q[search]=jane&q[tags_name_in][]=VIP&sort=-last_order_completed_at&per_page=50
json
{
  "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):

FilterExampleMaps to
q[search]?q[search]=janeSpree::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]=truenewsletter only
q[tags_name_in][]repeat per taginner-join acts_as_taggable_on
q[created_at_gteq], q[created_at_lteq]ISO datetimerange
q[orders_completed_at_gteq]last-active range — joins ordersalready 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/:id

http
GET /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

json
{
  "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/:id

Same 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/:id

Hard 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

json
{
  "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

json
{
  "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/:id

Toggle defaults via { "is_default_billing": true }. Soft-deletes (paranoia on Spree::Address) are honoured.

DELETE /api/v3/admin/customers/:customer_id/addresses/:id

Soft-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_credits

Returns 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

json
{
  "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/:id

Update memo, category, amount (only if amount_used == 0).

DELETE /api/v3/admin/customers/:customer_id/store_credits/:id

Allowed only if amount_used == 0. Otherwise 422 store_credit_in_use.

Address management — alignment with existing AddressBook

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:

  1. Always read defaults from the join through the FKs. is_default_billing = (address.id == address.user.bill_address_id). Already implemented.
  2. Writing 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.
  3. Address validation is strict on admin writes. No "skip validation" escape hatch. If a merchant pastes a half-formed address, they get 422 with field-level errors — same details shape as the rest of the admin API.

From competitor parity + research:

  1. Type-ahead on email/name/phone — ?q[search]=jane. Single param, full-text-ish.
  2. Tag chip filter?q[tags_name_in][]=VIP&q[tags_name_in][]=wholesale. AND between facets, OR within (Ransack default for _in).
  3. Last activity?q[orders_completed_at_gteq]=2026-01-01 (active in 2026). ?sort=-last_order_completed_at.
  4. High value?q[orders_total_gteq]=1000 (lifetime spend ≥ $1000). New Ransack scope, joins + sums orders.
  5. Newsletter?q[accepts_email_marketing_eq]=true.
  6. Date filters?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.

Forward-compat to 6.0

Today (5.5)6.0
Spree::UserSpree::Customer
cus_xxx prefixcust_xxx prefix
Spree.user_classSpree.customer_class
bill_address_id, ship_address_id columnssame column names — no migration
internal_note (ActionText)internal_note_html field stored as text per 6.0-rich-text-descriptions.md
accepts_email_marketingunchanged
URL /customersunchanged
customer_id field on Address admin payloadunchanged (already customer_id, not user_id, in current admin address serializer)
ransackable_attributes includes id email first_name last_name accepts_email_marketingextended with phone, created_at, last_sign_in_at
No customer_groups fieldadds customer_group_ids array (groups plan ships post-5.5)
No failed_attempts lockout adminplatform-auth lockout adds locked_at field

Field renames triggered by other plans:

  • Already aligned: 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.
  • metafieldcustom_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.

Out of scope for 5.5

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)
  • Customer segmentation (Shopify-style; not on Spree roadmap)
  • Marketing consent opt-in level + collection source (Shopify-style; deferred — accepts_email_marketing boolean is enough for MVP)
  • Tax-exempt customer flag (B2B 6.0-channels-catalogs-b2b.md owns it via Company)
  • Avatar in payload (UI-only need, deferred to admin SPA plan)

Migration Path

This is a new endpoint family. No backfills, no data migration.

  1. Extend 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.
  2. Extend Spree::Api::V3::Admin::CustomerSerializer with the new fields above. Run typelizer + zod regeneration per CLAUDE.md pipeline.
  3. Add 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).
  4. Add 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.
  5. Add Spree::Api::V3::Admin::Customers::StoreCreditsController (/customers/:customer_id/store_credits). Existing serializer is reusable.
  6. Add cache key 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.
  7. Add SDK resources in @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}.
  8. Flip the seven checkboxes in 6.0-admin-api.md lines 503-507, 514-517, 520-524 as endpoints ship.

Constraints on Current Work

  • Use Spree.user_class in controller code; do not hardcode Spree::User or Spree::Customer. The 6.0 rename swaps the constant.
  • Never expose encrypted_password, password_salt, reset_password_token, confirmation_token, unlock_token in any serializer.
  • Never write to 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.
  • Address validation is server-side only. The SDK does not pre-validate; expect 422 with field-level errors.
  • All datetimes are ISO 8601 UTC strings with the iso8601 Alba directive.
  • total_spent is a string, per Alba :oj_rails BigDecimal-as-string convention.
  • List endpoint must use accessible_by(current_ability, :show) before any other scoping — admin role gating is upstream of store filtering.

Open Questions — RESOLVED

  1. Store filtering scope → Customers are NOT store-scoped. List returns all users (matching current behavior of Spree.user_class.for_store(store) returning self).
  2. Welcome / invitation email on POST → No automatic email. Merchants call password-reset endpoint explicitly if needed.
  3. internal_note_html direction → Yes — accept ActionText storage, expose internal_note_html (sanitised) as the API contract. Matches products' description_html.
  4. Order count + spend cache invalidation → Cache via subscriber is the right design but deferred for now — initial implementation computes inline (acceptable for MVP).
  5. Tags write semantics → Replace. tags: [...] in PATCH replaces the full set.

References

  • docs/plans/6.0-admin-api.md — endpoint placeholders this plan fills in (lines 502-525)
  • docs/plans/6.0-platform-auth.mdSpree.user_classSpree.customer_class, cus_cust_ prefix
  • docs/plans/5.4-6.0-eu-legal-compliance.md — marketing-consent timestamp, deferred anonymise/export endpoints
  • docs/plans/5.4-store-api-naming-standardization.md — address field renames already applied
  • spree/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 extend
  • spree/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 :cus
  • spree/core/lib/spree/permitted_attributes.rb lines 300-303 — user_attributes allowlist (reuse as-is)
  • Shopify Admin REST customer reference, Admin GraphQL Customer type, Medusa v2 admin customer SDK, Vendure Customer GraphQL type, Saleor User GraphQL type — competitor study sources