---
title: "Customers & leads"
description: "Read the customers on your store and upsert leads by email address."
---

> API Reference Index
> Fetch the complete API index at: https://docs.firearmcart.com/api/llms.txt
> Use this file to discover all available pages before exploring further.

# Customers & leads

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](/api/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](/api/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.

- **Operation id:** `customers.list`
- **Required ability:** `customers:read`
- **Rate limit:** 60 requests/minute per team

#### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | no | 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_since` | string | no | 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` |
| `page` | integer | no | Page number, starting at 1. Defaults to 1. Example: `2` |
| `per_page` | integer | no | Records per page. Defaults to 25 and is capped at 100 — a larger value is silently reduced to 100. Example: `50` |

#### Request

```bash
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"
```

#### Responses

##### 200 — A page of customers, wrapped in the standard paginator envelope.

```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": "&laquo; Previous",
        "page": null,
        "active": false
      },
      {
        "url": "https://api.firearmcart.com/v1/customers?page=1",
        "label": "1",
        "page": 1,
        "active": true
      },
      {
        "url": null,
        "label": "Next &raquo;",
        "page": null,
        "active": false
      }
    ],
    "path": "https://api.firearmcart.com/v1/customers",
    "per_page": 25,
    "to": 1,
    "total": 1
  }
}
```

##### 401 — Missing, malformed, or revoked API token.

```json
{
  "message": "Unauthenticated."
}
```

##### 403 — The 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.

```json
{
  "message": "Invalid ability provided."
}
```

#### 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.

## 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.

- **Operation id:** `customers.get`
- **Required ability:** `customers:read`
- **Rate limit:** 60 requests/minute per team

#### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `customer_id` | integer | yes | The customer's `customer_id` — the store-scoped sequential number the Customers API returns, not an underlying database key. Example: `1042` |

#### Request

```bash
curl "https://api.firearmcart.com/v1/customers/1042" \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json"
```

#### Responses

##### 200 — The customer record.

```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"
  }
}
```

##### 404 — No customer with that customer_id on your store. Lookups are team-scoped, so another store's customer reads as missing rather than forbidden.

```json
{
  "error": "not_found",
  "message": "Customer not found."
}
```

##### 401 — Missing, malformed, or revoked API token.

```json
{
  "message": "Unauthenticated."
}
```

#### 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.

## 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.

- **Operation id:** `leads.create`
- **Required ability:** `customers:write`
- **Rate limit:** 60 requests/minute per team

#### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | yes | 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_name` | string | no | Max 255 characters. Omitted on an update, the stored value is kept. Example: `Jordan` |
| `last_name` | string | no | Max 255 characters. Omitted on an update, the stored value is kept. Example: `Reyes` |
| `phone` | string | no | Max 20 characters. Stored verbatim — no formatting or country-code normalization is applied. Example: `+15125550142` |
| `accepts_marketing` | boolean | no | Marketing opt-in. Defaults to false on create; omitted on an update, the current value is kept. Example: `true` |
| `address` | object | no | An address to attach to the customer. Sending this object makes address_line1, city, state and zip_code required. |
| `address.type` | string | no | 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_line1` | string | no | Required when address is present. Max 255 characters. Example: `123 Congress Ave` |
| `address.address_line2` | string | no | Max 255 characters. Example: `Suite 400` |
| `address.city` | string | no | Required when address is present. Max 255 characters. Example: `Austin` |
| `address.state` | string | no | Required when address is present. Max 2 characters — the two-letter US state or territory code. Example: `TX` |
| `address.zip_code` | string | no | Required when address is present. Max 20 characters. Example: `78701` |

#### Request

```bash
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"
    }
  }'
```

#### Responses

##### 201 — No customer had this email, so one was created.

```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"
  }
}
```

##### 200 — An 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.

```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"
  }
}
```

##### 422 — Validation failed. The stock validation envelope: a summary message plus a field-keyed errors map.

```json
{
  "message": "The email field must be a valid email address.",
  "errors": {
    "email": [
      "The email field must be a valid email address."
    ]
  }
}
```

##### 403 — The token lacks the customers:write ability.

```json
{
  "message": "Invalid ability provided."
}
```

#### 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.

Source: https://docs.firearmcart.com/api/resources/customers/index.mdx
