Skip to content

Payments

Tokenize a card in a hosted iframe, then charge it and create the order in one call.

Updated View as Markdown

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.

<iframe id="fc-payment" src="IFRAME_URL_FROM_THE_TOKENIZE_RESPONSE"
        style="border:0;width:100%;height:200px"></iframe>
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 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 and Products hand out. Two identifiers here are UUIDs rather than team-scoped numbers:

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 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 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.
Required ability
payments:tokenize
Rate limit
60 requests/minute per team

Body parameters

stylingobjectoptional
CSS overrides for the card form. Only the eight keys below survive validation — any other key is stripped before it reaches the form.
styling.fontFamilystringoptional
Font stack for the form. Max 255 characters.
Example: Inter, sans-serif
styling.fontSizestringoptional
Base font size; labels and error text derive from it. Max 20 characters.
Example: 16px
styling.borderRadiusstringoptional
Input corner radius. Max 20 characters.
Example: 8px
styling.borderColorstringoptional
Resting input border colour. Max 20 characters.
Example: #e5e7eb
styling.focusBorderColorstringoptional
Focused input border colour; also tints the focus ring. Max 20 characters.
Example: #3b82f6
styling.backgroundColorstringoptional
Input background colour. Max 20 characters.
Example: #ffffff
styling.textColorstringoptional
Input text colour. Max 20 characters.
Example: #111827
styling.errorColorstringoptional
Validation message colour. Max 20 characters.
Example: #ef4444
allowed_originsarray<string>optional
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"]
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.
Requestbash
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"
    }
  }'
200Session created. Embed iframe_url and wait for the ready message.
{
  "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"
}
403The token is missing the payments:tokenize ability.
{
  "message": "Invalid ability provided."
}
422Validation failed. Stock framework envelope — a summary message plus a per-field errors map.
{
  "message": "The allowed origins field must be an array.",
  "errors": {
    "allowed_origins": [
      "The allowed origins field must be an array."
    ]
  }
}

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.
Required ability
payments:charge
Rate limit
30 requests/minute per team

Body parameters

customer_idintegeroptional
The customer's `customer_id`. Required unless email is supplied. The customer must already exist — this endpoint never creates one.
Example: 1042
emailstringoptional
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_typestringrequired
Either token (charge a freshly tokenized card) or card_on_file (charge the customer's most recent unexpired saved card).
Example: token
tokenstringoptional
The UUID handed back by the tokenization iframe. Required when payment_type is token, ignored otherwise.
Example: 1c9a4e60-7f5d-4a63-9c11-8d0e3b6f2a77
itemsarray<object>required
Line items to charge. At least one is required.
items[].product_idintegerrequired
The product's `product_id`, scoped to your store.
Example: 501
items[].variant_idintegeroptional
The variant's `variant_id`. Scoped to the product above, so the same value can repeat across products.
Example: 1201
items[].quantityintegerrequired
Units to charge. Minimum 1.
Example: 2
items[].priceintegeroptional
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[].cadencestringoptional
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_idstring (uuid)optional
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
shippingintegeroptional
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
taxintegeroptional
Tax override, in CENTS. Sending it skips tax calculation entirely — no tax plugin call and no tax-zone lookup.
Example: 907
shipping_addressobjectrequired
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_line1stringrequired
Street address. Max 255 characters.
Example: 123 Main St
shipping_address.address_line2stringoptional
Apartment, suite or unit. Max 255 characters.
Example: Apt 4
shipping_address.citystringrequired
City. Max 255 characters.
Example: Austin
shipping_address.statestringrequired
Two-letter US state code. Max 2 characters, and the tax-zone lookup matches on it exactly.
Example: TX
shipping_address.zip_codestringrequired
Postal code. Max 20 characters.
Example: 78701
billing_addressobjectoptional
Cardholder address sent to the gateway with the authorization. Omit it and the shipping address is reused for billing.
billing_address.address_line1stringoptional
Street address. Required once billing_address is present. Max 255 characters.
Example: 500 Congress Ave
billing_address.address_line2stringoptional
Apartment, suite or unit. Max 255 characters.
Example: Suite 200
billing_address.citystringoptional
City. Required once billing_address is present. Max 255 characters.
Example: Austin
billing_address.statestringoptional
Two-letter US state code. Required once billing_address is present. Max 2 characters.
Example: TX
billing_address.zip_codestringoptional
Postal code. Required once billing_address is present. Max 20 characters.
Example: 78701
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.
Requestbash
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"
    }
  }'
200Charge approved. Order amounts are float dollars; transaction.amount is integer cents.
{
  "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"
  }
}
400The 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.
{
  "success": false,
  "error": "product_not_found",
  "message": "Product with ID 999 not found."
}
404No customer on your team matched customer_id or email. This endpoint never creates customers — use POST /v1/leads first.
{
  "success": false,
  "error": "customer_not_found",
  "message": "Customer not found."
}
422The 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.
{
  "success": false,
  "error": "payment_declined",
  "message": "Do not honor",
  "details": {
    "response_code": "05",
    "response_message": "Do not honor"
  }
}
429Payment throttle exceeded (30/minute per team). Retry-After and X-RateLimit-* headers accompany the response.
{
  "message": "Too Many Attempts."
}
500Anything 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.
{
  "success": false,
  "error": "processing_error",
  "message": "An error occurred while processing the payment."
}
Navigation

Type to search…

↑↓ navigate↵ selectEsc close