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 thetokenyou received.card_on_file— the customer’s most recently saved card that has not expired. Notokenis 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:
shipping_rate_id, whose value is therate_idUUID from Shipping, taxation & FFL dealers.transaction.transaction_idon 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 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
/v1/payments/tokenize- 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"]
- 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.
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"
}
}'{
"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"
}{
"message": "Invalid ability provided."
}{
"message": "The allowed origins field must be an array.",
"errors": {
"allowed_origins": [
"The allowed origins field must be an array."
]
}
}Charge a card
/v1/payments/charge- 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
- 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.
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"
}
}'{
"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"
}
}{
"success": false,
"error": "product_not_found",
"message": "Product with ID 999 not found."
}{
"success": false,
"error": "customer_not_found",
"message": "Customer not found."
}{
"success": false,
"error": "payment_declined",
"message": "Do not honor",
"details": {
"response_code": "05",
"response_message": "Do not honor"
}
}{
"message": "Too Many Attempts."
}{
"success": false,
"error": "processing_error",
"message": "An error occurred while processing the payment."
}
