---
title: "Payments"
description: "Tokenize a card in a hosted iframe, then charge it and create the order in one call."
---

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

# Payments

Payments are a two-step flow. You open a short-lived tokenization session, embed the hosted card
form it returns, and receive a single-use token when the cardholder submits. You then send that
token to the charge endpoint, which builds the order from your catalog, prices it, and authorizes
the card — all in one request.

Raw card data never touches your servers. The form is served from `secure.firearmcart.com`, a
separate origin from both your storefront and the API, and the only thing that crosses back into
your page is a token plus the card's brand and last four digits.

## Choosing a payment type

`payment_type` decides where the card comes from:

- **`token`** — a card the shopper just entered in the iframe. Pass the `token` you received.
- **`card_on_file`** — the customer's most recently saved card that has not expired. No `token` is
  needed, but the customer must already have one; there is no way to select a specific saved card.

## Talking to the iframe

The form communicates entirely over `postMessage`. Send `allowed_origins` when you create the
session — it is the allowlist the iframe checks on every inbound message, and an empty allowlist
blocks your own `tokenize` command, leaving the form unsubmittable.

```html
<iframe id="fc-payment" src="IFRAME_URL_FROM_THE_TOKENIZE_RESPONSE"
    style="border:0;width:100%;height:200px"></iframe>
```

```js
const SECURE_ORIGIN = "https://secure.firearmcart.com";

window.addEventListener("message", (event) => {
  if (event.origin !== SECURE_ORIGIN) return;

  switch (event.data.type) {
case "ready":
  // { height } — resize the iframe to fit the form.
  document.getElementById("fc-payment").style.height =
    `${event.data.payload.height}px`;
  break;
case "token":
  // { token, card: { last_four, brand, exp_month, exp_year } }
  chargeWithToken(event.data.payload.token);
  break;
case "error":
  // payload is a plain string.
  console.error(event.data.payload);
  break;
  }
});

// Nothing else submits the form — there is no button inside the iframe.
document.getElementById("fc-payment").contentWindow.postMessage(
  { type: "tokenize" },
  SECURE_ORIGIN,
);
```

Outbound messages go to the **first** entry in `allowed_origins`, so list the embedding page's
origin first. Sessions are single-use and expire after 15 minutes; create a fresh one for every
checkout attempt.

## Money units

The charge endpoint is the one place in the API where both money conventions meet in a single
exchange, so read them per field rather than per endpoint:

| Direction | Fields | Unit |
|-----------|--------|------|
| Request | `items[].price`, `shipping`, `tax` | Integer **cents** |
| Response | `order.subtotal`, `order.tax`, `order.shipping`, `order.total`, `items[].price`, `items[].subtotal` | Float **dollars** |
| Response | `transaction.amount` | Integer **cents** |

That last row is a genuine inconsistency, not a typo: the `transaction` object on the charge
response is hand-built and converts to cents, while the `transactions[]` array returned by
[Orders](/api/resources/orders) uses `TransactionResource`, which emits dollars.

## Identifiers

`customer_id`, `product_id` and `items[].variant_id` are store-scoped ids — per-store
sequential integers, the same ones [Customers](/api/resources/customers) and Products hand out.
Two identifiers here are UUIDs rather than team-scoped numbers:

- `shipping_rate_id`, whose value is the `rate_id` UUID from [Shipping, taxation & FFL dealers](/api/resources/shipping-taxation-ffl).
- `transaction.transaction_id` on the charge response.

## Failure modes worth planning for

There is no idempotency key. If a charge request times out on your side, you cannot safely retry
it — the authorization may already have gone through and a retry creates a second order and a
second charge. Reconcile against [Orders](/api/resources/orders) instead.

Declines are recorded, not discarded. A declined card leaves a real order in status `failed` with
a failed transaction attached, and both are visible in the dashboard and over the API.

Fraud controls fail closed and opaque. Blacklist matches and the US-cards-only and block-prepaid
checkout settings all reject before the gateway is contacted, and they surface as
`500 processing_error` rather than a specific 4xx — deliberately, so the response cannot be used
to probe your rules.

Every request on this page also needs a token with the matching ability; see
[Authentication](/api/authentication) for how abilities are granted. Charging is throttled harder
than the rest of the API at 30 requests per minute per team.

## Create a tokenization session

`POST /v1/payments/tokenize`

Opens a 15-minute, single-use session for the hosted card form and returns an iframe URL to embed. The cardholder types their card on secure.firearmcart.com, so raw PAN and CVV never reach your server; the iframe posts a single-use token back to the parent window, which you then send to POST /v1/payments/charge.

- **Operation id:** `payments.tokenize`
- **Required ability:** `payments:tokenize`
- **Rate limit:** 60 requests/minute per team

#### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `styling` | object | no | CSS overrides for the card form. Only the eight keys below survive validation — any other key is stripped before it reaches the form. |
| `styling.fontFamily` | string | no | Font stack for the form. Max 255 characters. Example: `Inter, sans-serif` |
| `styling.fontSize` | string | no | Base font size; labels and error text derive from it. Max 20 characters. Example: `16px` |
| `styling.borderRadius` | string | no | Input corner radius. Max 20 characters. Example: `8px` |
| `styling.borderColor` | string | no | Resting input border colour. Max 20 characters. Example: `#e5e7eb` |
| `styling.focusBorderColor` | string | no | Focused input border colour; also tints the focus ring. Max 20 characters. Example: `#3b82f6` |
| `styling.backgroundColor` | string | no | Input background colour. Max 20 characters. Example: `#ffffff` |
| `styling.textColor` | string | no | Input text colour. Max 20 characters. Example: `#111827` |
| `styling.errorColor` | string | no | Validation message colour. Max 20 characters. Example: `#ef4444` |
| `allowed_origins` | array<string> | no | Origins permitted to exchange postMessage with the iframe. Each entry must be a valid URL. Optional to the validator but required in practice — see the caveats. Example: `["https://shop.example.com"]` |

#### Request

```bash
curl -X POST "https://api.firearmcart.com/v1/payments/tokenize" \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "allowed_origins": ["https://shop.example.com"],
    "styling": {
      "fontFamily": "Inter, sans-serif",
      "fontSize": "16px",
      "borderRadius": "8px",
      "focusBorderColor": "#3b82f6"
    }
  }'
```

#### Responses

##### 200 — Session created. Embed iframe_url and wait for the ready message.

```json
{
  "session_id": "9f3d2b7e-6c41-4b0a-9a1d-2f8c5e7a1b34",
  "iframe_url": "https://secure.firearmcart.com/api-payment-form/9f3d2b7e-6c41-4b0a-9a1d-2f8c5e7a1b34",
  "expires_at": "2026-08-11T14:15:00+00:00"
}
```

##### 403 — The token is missing the payments:tokenize ability.

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

##### 422 — Validation failed. Stock framework envelope — a summary message plus a per-field errors map.

```json
{
  "message": "The allowed origins field must be an array.",
  "errors": {
    "allowed_origins": [
      "The allowed origins field must be an array."
    ]
  }
}
```

#### Caveats

- allowed_origins is optional to the validator but effectively required. Omit it and the session stores an empty allowlist, which makes the iframe reject every inbound message from the parent window — including the {type: 'tokenize'} command that submits the form. The form ships no submit button, so tokenization can never fire. Always send at least your own origin.
- The iframe posts its token, error and ready messages to allowed_origins[0] only. Extra entries widen the inbound allowlist but never receive outbound messages, so list the embedding origin first.
- Only fontFamily, fontSize, borderRadius, borderColor, focusBorderColor, backgroundColor, textColor and errorColor are honoured. labelColor and placeholderColor appear in older FirearmCart documentation but are dropped by validation before they reach the form, which falls back to its defaults (#374151 and #9ca3af).
- The session is single-use: it is deleted the instant a card is tokenized, and it expires 15 minutes after creation either way. Re-loading a spent or expired iframe URL returns HTTP 410. Create a fresh session per checkout attempt.
- The card form applies its own throttle of 10 tokenizations per 10 minutes per end-user IP, independently of this endpoint's 60/minute team limit.
- expires_at is computed at response time as now + 15 minutes, and is returned as an ISO-8601 string with a UTC offset.

## Charge a card

`POST /v1/payments/charge`

Creates an order from the supplied line items and charges it in a single call. Totals are computed server-side from your catalog, your shipping rates and your tax configuration unless you override them. The order row is persisted either way — a decline leaves a real order in status failed alongside a failed transaction.

- **Operation id:** `payments.charge`
- **Required ability:** `payments:charge`
- **Rate limit:** 30 requests/minute per team

#### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `customer_id` | integer | no | The customer's `customer_id`. Required unless email is supplied. The customer must already exist — this endpoint never creates one. Example: `1042` |
| `email` | string | no | Customer email, used to look the customer up when customer_id is absent. Required unless customer_id is supplied. Matched case-insensitively (trimmed and lowercased before the query). Max 255 characters. Example: `dana@example.com` |
| `payment_type` | string | yes | Either token (charge a freshly tokenized card) or card_on_file (charge the customer's most recent unexpired saved card). Example: `token` |
| `token` | string | no | The UUID handed back by the tokenization iframe. Required when payment_type is token, ignored otherwise. Example: `1c9a4e60-7f5d-4a63-9c11-8d0e3b6f2a77` |
| `items` | array<object> | yes | Line items to charge. At least one is required. |
| `items[].product_id` | integer | yes | The product's `product_id`, scoped to your store. Example: `501` |
| `items[].variant_id` | integer | no | The variant's `variant_id`. Scoped to the product above, so the same value can repeat across products. Example: `1201` |
| `items[].quantity` | integer | yes | Units to charge. Minimum 1. Example: `2` |
| `items[].price` | integer | no | Unit price override, in CENTS. Omit to use the variant's catalog price, or the product's when no variant is given. Example: `4999` |
| `items[].cadence` | string | no | Turns the line into a Reload subscription. One of monthly, quarterly, semiannual, yearly. The product must be subscription-eligible and must offer the cadence; serialized firearms are always rejected. Example: `monthly` |
| `shipping_rate_id` | string (uuid) | no | The rate_id returned by GET /v1/shipping — the rate's UUID. Resolved server-side and verified to belong to your team, then stored on the order and used as the shipping amount. Example: `4f8a2d17-9b3c-4e51-a6d8-0c7e5b2f9a14` |
| `shipping` | integer | no | Shipping override, in CENTS. Wins over the price of shipping_rate_id when both are sent (the rate id is still recorded on the order). Example: `999` |
| `tax` | integer | no | Tax override, in CENTS. Sending it skips tax calculation entirely — no tax plugin call and no tax-zone lookup. Example: `907` |
| `shipping_address` | object | yes | Destination address. Always required, even for a wholly digital order, because it also drives tax calculation. US addresses only — there is no country field and the tax call hardcodes US. |
| `shipping_address.address_line1` | string | yes | Street address. Max 255 characters. Example: `123 Main St` |
| `shipping_address.address_line2` | string | no | Apartment, suite or unit. Max 255 characters. Example: `Apt 4` |
| `shipping_address.city` | string | yes | City. Max 255 characters. Example: `Austin` |
| `shipping_address.state` | string | yes | Two-letter US state code. Max 2 characters, and the tax-zone lookup matches on it exactly. Example: `TX` |
| `shipping_address.zip_code` | string | yes | Postal code. Max 20 characters. Example: `78701` |
| `billing_address` | object | no | Cardholder address sent to the gateway with the authorization. Omit it and the shipping address is reused for billing. |
| `billing_address.address_line1` | string | no | Street address. Required once billing_address is present. Max 255 characters. Example: `500 Congress Ave` |
| `billing_address.address_line2` | string | no | Apartment, suite or unit. Max 255 characters. Example: `Suite 200` |
| `billing_address.city` | string | no | City. Required once billing_address is present. Max 255 characters. Example: `Austin` |
| `billing_address.state` | string | no | Two-letter US state code. Required once billing_address is present. Max 2 characters. Example: `TX` |
| `billing_address.zip_code` | string | no | Postal code. Required once billing_address is present. Max 20 characters. Example: `78701` |

#### Request

```bash
curl -X POST "https://api.firearmcart.com/v1/payments/charge" \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "customer_id": 1042,
    "payment_type": "token",
    "token": "1c9a4e60-7f5d-4a63-9c11-8d0e3b6f2a77",
    "items": [
      { "product_id": 501, "variant_id": 1201, "quantity": 2 }
    ],
    "shipping_rate_id": "4f8a2d17-9b3c-4e51-a6d8-0c7e5b2f9a14",
    "shipping_address": {
      "address_line1": "123 Main St",
      "address_line2": "Apt 4",
      "city": "Austin",
      "state": "TX",
      "zip_code": "78701"
    }
  }'
```

#### Responses

##### 200 — Charge approved. Order amounts are float dollars; transaction.amount is integer cents.

```json
{
  "success": true,
  "order": {
    "order_id": 5001,
    "customer_id": 1042,
    "status": "completed",
    "subtotal": 99.98,
    "tax": 9.07,
    "shipping": 9.99,
    "shipping_insurance": 0,
    "surcharge": 0,
    "discount": 0,
    "total": 119.04,
    "items": [
      {
        "product_id": 501,
        "variant_id": 1201,
        "name": "Ranger 9mm Compact",
        "variant_name": "Finish: Black / Capacity: 15rd",
        "sku": "RNG-9C-BLK-15",
        "quantity": 2,
        "price": 49.99,
        "subtotal": 99.98,
        "is_cancelled": false,
        "is_recurring": false,
        "properties": null
      }
    ],
    "shipping_address": {
      "address_id": "6e1d9f27-3c4b-4a85-b1e0-5d8c2f7a94b3",
      "type": "shipping",
      "address_line1": "123 Main St",
      "address_line2": "Apt 4",
      "city": "Austin",
      "state": "TX",
      "zip_code": "78701",
      "is_billing": false,
      "is_shipping": true,
      "is_default_billing": false,
      "is_default_shipping": false
    },
    "billing_address": {
      "address_id": "6e1d9f27-3c4b-4a85-b1e0-5d8c2f7a94b3",
      "type": "shipping",
      "address_line1": "123 Main St",
      "address_line2": "Apt 4",
      "city": "Austin",
      "state": "TX",
      "zip_code": "78701",
      "is_billing": false,
      "is_shipping": true,
      "is_default_billing": false,
      "is_default_shipping": false
    },
    "created_at": "2026-08-11T14:02:11+00:00",
    "updated_at": "2026-08-11T14:02:13+00:00"
  },
  "transaction": {
    "transaction_id": "a7c3e9f1-52b8-4d6a-9e04-8b1f6c2d3a90",
    "gateway_transaction_id": "ch_3PqR7dK2xYzA1b",
    "amount": 11904,
    "status": "completed"
  }
}
```

##### 400 — The request was well-formed but something in it could not be resolved: payment_method_not_found, product_not_found, variant_not_found, subscription_not_allowed, subscription_not_available, cadence_not_offered or subscription_required.

```json
{
  "success": false,
  "error": "product_not_found",
  "message": "Product with ID 999 not found."
}
```

##### 404 — No customer on your team matched customer_id or email. This endpoint never creates customers — use POST /v1/leads first.

```json
{
  "success": false,
  "error": "customer_not_found",
  "message": "Customer not found."
}
```

##### 422 — The gateway declined the card. The order and a failed transaction are both committed. Request-validation failures also return 422, but with the stock message/errors envelope instead.

```json
{
  "success": false,
  "error": "payment_declined",
  "message": "Do not honor",
  "details": {
    "response_code": "05",
    "response_message": "Do not honor"
  }
}
```

##### 429 — Payment throttle exceeded (30/minute per team). Retry-After and X-RateLimit-* headers accompany the response.

```json
{
  "message": "Too Many Attempts."
}
```

##### 500 — Anything that is not a gateway decline: no active merchant account, a blacklist match, or a card blocked by your US-cards-only or block-prepaid setting. The real reason is deliberately not disclosed.

```json
{
  "success": false,
  "error": "processing_error",
  "message": "An error occurred while processing the payment."
}
```

#### Caveats

- Units are mixed in a single exchange. Every amount you SEND — items[].price, shipping, tax — is integer cents. Every amount you get BACK on the order and its items is a float in dollars. The one exception is transaction.amount on this response, which is integer cents even though TransactionResource emits dollars everywhere else in the API.
- There is no idempotency key. A retried or duplicated request creates a second order and runs a second authorization. Deduplicate on your side before retrying a request whose response you did not see.
- A decline is not a rollback. The order is committed in status failed with a failed transaction attached, so declined attempts accumulate as real orders visible in the dashboard and in GET /v1/orders.
- Fraud-control rejections are reported as 500 processing_error, not as a 4xx. A blacklisted customer, a non-US card under checkout_us_cards_only, or a prepaid card under checkout_block_prepaid all throw before the gateway is contacted and surface with the generic processing_error message.
- An unknown or foreign shipping_rate_id is silently ignored rather than rejected: shipping stays 0 and the order records no rate. A value that is not a well-formed UUID fails validation with a 422. Confirm the rate_id against GET /v1/shipping before charging, or send an explicit shipping override.
- Omitting tax does not guarantee tax is charged. With no active tax plugin the fallback is a manual tax-zone match on shipping_address.state; if no active zone covers that state, tax is 0. Tax is computed on subtotal plus shipping.
- Each charge INSERTs a new address row — there is no lookup or dedupe — so repeat customers accumulate one shipping address per order. When billing_address is omitted the order points both its billing and shipping FKs at that single new row, which is why the response shows is_billing false on it.
- The token from the tokenization iframe is single-use in practice. Its CVV is read from cache and deleted on the first charge attempt, and a matching card already on file absorbs the token, so a second charge with the same token authorizes without a CVV or fails outright. Tokenize again per attempt.
- payment_type card_on_file picks the customer's most recently created unexpired card with no further filtering — you cannot choose which saved card is used. payment_type token skips that expiry filter entirely, so an already-expired card can be tokenized and sent to the gateway.
- A cadence line creates the Subscription row synchronously, before this response is returned, but the returned order item still reads is_recurring false — promotion never flips that column. Confirm via GET /v1/subscriptions. The subscription discount applies only to future rebills; this first charge bills the full catalog price.
- The order object carries only the relations that were loaded during the charge: items, shipping_address and billing_address. transactions, payment_method and fulfillments are always absent — the status derivation re-queries the ledger rather than loading the relation, so nothing populates them. Call GET /v1/orders/{order_id} when you need the complete object.
- When you omit billing_address, the response still returns one — it is the same row as shipping_address, because the order points both foreign keys at the single address the charge inserted. That is why it reads type shipping and is_billing false.
- status is derived from the transaction ledger, not stored blindly. A successful charge reads completed (not confirmed) and a decline reads failed.

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