A customer is a person who has bought from your store, or who has handed you an email address on the way to buying. There is one customer record per email address per store, and everything else — orders, subscriptions, saved addresses, marketing consent — hangs off it.
This group covers the read side (list and retrieve) and a single write endpoint. POST /v1/leads is an upsert keyed on email: send it whatever you know about a person and it either creates the customer or updates the one that already exists. That makes it safe to call from a newsletter form, a quote request, or a partner integration that replays the same contact repeatedly.
If you need to know when a customer record appears or changes rather than polling for it, the customer.created and customer.updated webhooks fire on every path that writes one, including POST /v1/leads. See Webhooks for how deliveries are shaped.
There is no delete endpoint, and no way to change an email address. A customer’s email is their identity here; upserting a new address creates a second customer rather than renaming the first.
Authentication and abilities
Every request needs a team API token — see Authentication. Tokens belong to a store, not to a user, so the customers you can read are exactly the customers on the store that issued the token. Reads require the customers:read ability and POST /v1/leads requires customers:write; a token missing the ability gets a 403 even though the endpoint exists.
Always send Accept: application/json. Without it, an error is rendered as an HTML page rather than a JSON body.
Identifiers
The customer_id in every response is the customer’s store-scoped number — a per-store sequential integer, and the value GET /v1/customers/{customer_id} expects in the path. It is not the underlying database primary key, and two different stores will both have a customer 1001.
The customer_id fields on orders, subscriptions and payments refer to this same number, so a customer record joins cleanly to the rest of the API.
Response conventions
GET /v1/customers returns the standard paginated envelope (data, links, meta). The two single-record endpoints return a bare object under data with no links or meta.
Nothing in this group is monetary, so the dollars-versus-cents split that applies elsewhere in the API does not come up here. Timestamps are ISO 8601 with an explicit UTC offset, for example 2026-07-14T18:22:05+00:00.
addresses is always present on both endpoints, because both controllers eager-load the relation. An empty array means the customer has no saved address, not that the field was omitted.
Things that bite
- Pagination links drop your filters. The URLs in
linksandmeta.linkscontain onlypage. Followinglinks.nextverbatim re-runs the query with noemail, nocreated_sinceand the default page size. Keep your own query string and changepageyourself. - The
emailfilter is case-sensitive. Stored addresses are lowercased on write, so a mixed-case filter value silently matches nothing. tagsis read-only. It appears in responses but is not accepted onPOST /v1/leads. Tag customers from the dashboard.- Fields cannot be cleared. The lead upsert ignores nulls, so an omitted or null
first_nameleaves the stored value alone. - Addresses are US-only. There is no country field and
stateis a two-letter code.
Per-endpoint caveats are listed with each operation below.
List customers
/v1/customers- Required ability
customers:read- Rate limit
- 60 requests/minute per team
Query parameters
emailstringoptional- Partial email match. Applied as a SQL LIKE with wildcards on both sides, and it is case-sensitive — stored addresses are always lowercased, so pass a lowercase value.Example:
reyes@example.com created_sincestringoptional- Only return customers created at or after this timestamp. Compared against created_at, which is stored in UTC. Any timestamp the database can parse works — an ISO 8601 string or a plain Y-m-d date.Example:
2026-08-01T00:00:00Z pageintegeroptional- Page number, starting at 1. Defaults to 1.Example:
2 per_pageintegeroptional- Records per page. Defaults to 25 and is capped at 100 — a larger value is silently reduced to 100.Example:
50
- Pagination URLs carry only the page parameter. links.next and meta.links drop your email, created_since and per_page values, so following those URLs verbatim re-runs an unfiltered query at 25 per page. Build page URLs yourself and keep your filters attached.
- The email filter is case-sensitive and unescaped. Stored addresses are always lowercased on write, so a value like Jordan@Example.com matches nothing. The % and _ characters in your value are passed through as SQL wildcards.
- created_since is handed to the database without validation. An unparseable value fails at the query and surfaces as a 500 rather than a 422.
- per_page has an upper bound but no lower one. A value that casts to 0 — including a non-numeric string — falls back to 15 per page, and a negative value drops the LIMIT entirely and returns every customer on the store in one response.
- There is no sort parameter. Results are always ordered by created_at descending, so a record created mid-walk shifts everything onto a page you already fetched. Sync with created_since, not with page alone.
- Customers created by a test-mode payment are included and are not marked in the response — the is_test column is not exposed by CustomerResource.
- addresses is always present because the controller eager-loads the relation. An address serving both roles (is_billing and is_shipping both true) reports type as shipping; read is_billing and is_shipping for the real state.
curl -G "https://api.firearmcart.com/v1/customers" \
--data-urlencode "email=reyes@example.com" \
--data-urlencode "per_page=25" \
-H "Authorization: Bearer $FIREARMCART_TOKEN" \
-H "Accept: application/json"{
"data": [
{
"customer_id": 1042,
"email": "jordan.reyes@example.com",
"first_name": "Jordan",
"last_name": "Reyes",
"phone": "+15125550142",
"accepts_marketing": true,
"tags": [
"vip"
],
"addresses": [
{
"address_id": "6e1d9f27-3c4b-4a85-b1e0-5d8c2f7a94b3",
"type": "shipping",
"address_line1": "123 Congress Ave",
"address_line2": "Suite 400",
"city": "Austin",
"state": "TX",
"zip_code": "78701",
"is_billing": false,
"is_shipping": true,
"is_default_billing": false,
"is_default_shipping": true
}
],
"created_at": "2026-07-14T18:22:05+00:00",
"updated_at": "2026-08-02T09:41:17+00:00"
}
],
"links": {
"first": "https://api.firearmcart.com/v1/customers?page=1",
"last": "https://api.firearmcart.com/v1/customers?page=1",
"prev": null,
"next": null
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 1,
"links": [
{
"url": null,
"label": "« Previous",
"page": null,
"active": false
},
{
"url": "https://api.firearmcart.com/v1/customers?page=1",
"label": "1",
"page": 1,
"active": true
},
{
"url": null,
"label": "Next »",
"page": null,
"active": false
}
],
"path": "https://api.firearmcart.com/v1/customers",
"per_page": 25,
"to": 1,
"total": 1
}
}{
"message": "Unauthenticated."
}{
"message": "Invalid ability provided."
}Retrieve a customer
/v1/customers/{customer_id}- Required ability
customers:read- Rate limit
- 60 requests/minute per team
Path parameters
customer_idintegerrequired- The customer's `customer_id` — the store-scoped sequential number the Customers API returns, not an underlying database key.Example:
1042
- This is the only endpoint in the group that returns the hand-written {error, message} envelope. Authentication, ability and validation failures everywhere else return the framework's stock {message} shape, plus an errors map on a 422.
- The path segment must be numeric. A non-numeric segment such as /v1/customers/abc fails type coercion before the controller runs and returns a 500, not a 404.
- Soft-deleted customers are excluded, so a customer removed from the dashboard starts returning 404 while their past orders stay intact.
curl "https://api.firearmcart.com/v1/customers/1042" \
-H "Authorization: Bearer $FIREARMCART_TOKEN" \
-H "Accept: application/json"{
"data": {
"customer_id": 1042,
"email": "jordan.reyes@example.com",
"first_name": "Jordan",
"last_name": "Reyes",
"phone": "+15125550142",
"accepts_marketing": true,
"tags": [
"vip"
],
"addresses": [
{
"address_id": "6e1d9f27-3c4b-4a85-b1e0-5d8c2f7a94b3",
"type": "shipping",
"address_line1": "123 Congress Ave",
"address_line2": "Suite 400",
"city": "Austin",
"state": "TX",
"zip_code": "78701",
"is_billing": false,
"is_shipping": true,
"is_default_billing": false,
"is_default_shipping": true
}
],
"created_at": "2026-07-14T18:22:05+00:00",
"updated_at": "2026-08-02T09:41:17+00:00"
}
}{
"error": "not_found",
"message": "Customer not found."
}{
"message": "Unauthenticated."
}Create or update a lead
/v1/leads- Required ability
customers:write- Rate limit
- 60 requests/minute per team
Body parameters
emailstringrequired- Email address, max 255 characters. Trimmed and lowercased before both the lookup and storage, so casing never creates a duplicate.Example:
jordan.reyes@example.com first_namestringoptional- Max 255 characters. Omitted on an update, the stored value is kept.Example:
Jordan last_namestringoptional- Max 255 characters. Omitted on an update, the stored value is kept.Example:
Reyes phonestringoptional- Max 20 characters. Stored verbatim — no formatting or country-code normalization is applied.Example:
+15125550142 accepts_marketingbooleanoptional- Marketing opt-in. Defaults to false on create; omitted on an update, the current value is kept.Example:
true addressobjectoptional- An address to attach to the customer. Sending this object makes address_line1, city, state and zip_code required.
address.typestringoptional- Either billing or shipping. Defaults to shipping. Sets the is_billing / is_shipping flags, which are mutually exclusive through this endpoint.Example:
shipping address.address_line1stringoptional- Required when address is present. Max 255 characters.Example:
123 Congress Ave address.address_line2stringoptional- Max 255 characters.Example:
Suite 400 address.citystringoptional- Required when address is present. Max 255 characters.Example:
Austin address.statestringoptional- Required when address is present. Max 2 characters — the two-letter US state or territory code.Example:
TX address.zip_codestringoptional- Required when address is present. Max 20 characters.Example:
78701
- tags is not an accepted request field, despite older documentation listing one. It is stripped by validation and silently ignored — the tags array in the response is read-only and is managed from the dashboard.
- You cannot clear a field. Nulls are filtered out before the update, so first_name: null leaves the stored first name in place; there is no way to blank a name or a phone number through this endpoint.
- The marketing subscribe event only fires on a false-to-true transition, or on create with accepts_marketing set to true. Re-posting true for an already opted-in customer is a no-op, so this is not a way to force a resync of your email platform.
- Addresses are matched on an exact address_line1 plus zip_code pair. On a match the existing row is updated in place and its role flags are overwritten by address.type — posting type shipping to a row that was billing-only sets is_billing to false.
- A newly created address claims the default billing or default shipping slot only when the customer has no default for that role yet. Existing defaults are never moved by this endpoint.
- Addresses are US-only: there is no country field, and state is capped at two characters.
- The four required_with rules apply individually, so an address object missing any of address_line1, city, state or zip_code fails validation.
- address_id is response-only. The address object you post accepts type, address_line1, address_line2, city, state and zip_code and nothing else — an address_id in the request body is stripped by validation, so there is no way to target a specific existing address. Matching is by address_line1 plus zip_code.
curl -X POST "https://api.firearmcart.com/v1/leads" \
-H "Authorization: Bearer $FIREARMCART_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"email": "jordan.reyes@example.com",
"first_name": "Jordan",
"last_name": "Reyes",
"phone": "+15125550142",
"accepts_marketing": true,
"address": {
"type": "shipping",
"address_line1": "123 Congress Ave",
"address_line2": "Suite 400",
"city": "Austin",
"state": "TX",
"zip_code": "78701"
}
}'{
"data": {
"customer_id": 1042,
"email": "jordan.reyes@example.com",
"first_name": "Jordan",
"last_name": "Reyes",
"phone": "+15125550142",
"accepts_marketing": true,
"tags": [
"vip"
],
"addresses": [
{
"address_id": "6e1d9f27-3c4b-4a85-b1e0-5d8c2f7a94b3",
"type": "shipping",
"address_line1": "123 Congress Ave",
"address_line2": "Suite 400",
"city": "Austin",
"state": "TX",
"zip_code": "78701",
"is_billing": false,
"is_shipping": true,
"is_default_billing": false,
"is_default_shipping": true
}
],
"created_at": "2026-07-14T18:22:05+00:00",
"updated_at": "2026-08-02T09:41:17+00:00"
}
}{
"data": {
"customer_id": 1042,
"email": "jordan.reyes@example.com",
"first_name": "Jordan",
"last_name": "Reyes",
"phone": "+15125550142",
"accepts_marketing": true,
"tags": [
"vip"
],
"addresses": [
{
"address_id": "6e1d9f27-3c4b-4a85-b1e0-5d8c2f7a94b3",
"type": "shipping",
"address_line1": "123 Congress Ave",
"address_line2": "Suite 400",
"city": "Austin",
"state": "TX",
"zip_code": "78701",
"is_billing": false,
"is_shipping": true,
"is_default_billing": false,
"is_default_shipping": true
}
],
"created_at": "2026-07-14T18:22:05+00:00",
"updated_at": "2026-08-02T09:41:17+00:00"
}
}{
"message": "The email field must be a valid email address.",
"errors": {
"email": [
"The email field must be a valid email address."
]
}
}{
"message": "Invalid ability provided."
}
