Skip to content

Customers & leads

Read the customers on your store and upsert leads by email address.

Updated View as Markdown

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 links and meta.links contain only page. Following links.next verbatim re-runs the query with no email, no created_since and the default page size. Keep your own query string and change page yourself.
  • The email filter is case-sensitive. Stored addresses are lowercased on write, so a mixed-case filter value silently matches nothing.
  • tags is read-only. It appears in responses but is not accepted on POST /v1/leads. Tag customers from the dashboard.
  • Fields cannot be cleared. The lead upsert ignores nulls, so an omitted or null first_name leaves the stored value alone.
  • Addresses are US-only. There is no country field and state is a two-letter code.

Per-endpoint caveats are listed with each operation below.

List customers

GET/v1/customers
Returns a paginated list of the customers on your store, newest first. Every record ships with its saved addresses already loaded. Use the email and created_since filters to narrow the set, or to poll for records created since your last sync.
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
Caveats
  • 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.
Requestbash
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"
200A page of customers, wrapped in the standard paginator envelope.
{
  "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
  }
}
401Missing, malformed, or revoked API token.
{
  "message": "Unauthenticated."
}
403The token is valid but lacks the customers:read ability. A team without API access enabled, or with an expired subscription, is also a 403 — but returns the {error, message} envelope instead.
{
  "message": "Invalid ability provided."
}

Retrieve a customer

GET/v1/customers/{customer_id}
Fetches a single customer by their store-scoped customer_id, with their saved addresses. The response is a bare object under data — there is no links or meta block.
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
Caveats
  • 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.
Requestbash
curl "https://api.firearmcart.com/v1/customers/1042" \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json"
200The customer record.
{
  "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"
  }
}
404No customer with that customer_id on your store. Lookups are team-scoped, so another store's customer reads as missing rather than forbidden.
{
  "error": "not_found",
  "message": "Customer not found."
}
401Missing, malformed, or revoked API token.
{
  "message": "Unauthenticated."
}

Create or update a lead

POST/v1/leads
Upserts a customer by email address. If the email already exists on your store the record is updated and 200 is returned; otherwise a customer is created and 201 is returned. Use it for newsletter signups, quote requests, and any pre-checkout capture.
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
Caveats
  • 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.
Requestbash
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"
    }
  }'
201No customer had this email, so one was created.
{
  "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"
  }
}
200An existing customer with this email was updated. The body is identical in shape to the 201 — read the status code to tell create from update.
{
  "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"
  }
}
422Validation failed. The stock validation envelope: a summary message plus a field-keyed errors map.
{
  "message": "The email field must be a valid email address.",
  "errors": {
    "email": [
      "The email field must be a valid email address."
    ]
  }
}
403The token lacks the customers:write ability.
{
  "message": "Invalid ability provided."
}
Navigation

Type to search…

↑↓ navigate↵ selectEsc close