Payments

Formahand Payments, PayPal Checkout and bank transfer or cash on delivery: the provider interface, storage, routes and how a store picks one.

Your store takes money through one payment provider at a time. Three are available today: Formahand Payments (card payments, provider id stripe), PayPal Checkout (your own PayPal business account, provider id paypal) and Bank transfer / cash on delivery ("manual payments", provider id manual, built in, nothing to open).

Grey label. Card payments are Formahand's own product: everything you and your buyers read says "Formahand Payments", or plainly "card payments". The processor behind it is named to you in exactly one sentence, shown in Payments while your account is being opened, because its terms require you to know who moves the money and whose agreement you are accepting — plus the processor's own payment frame or hosted page, which carries its own footer wherever a shopper pays (§6b), and Formahand's legal pages. The provider id stripe, and the processor-side parameter names in this document, keep the name too: they are what you and your agent match on when you integrate webhooks or read an order back.

Manual payments exist for markets card payments do not reach — Vietnam and most of Southeast Asia — and for merchants who simply want to confirm each payment themselves.

Card payments by country

Formahand Payments opens card accounts for businesses registered in Australia, Canada, Hong Kong, Japan, Malaysia, Mexico, New Zealand, Singapore, Switzerland, Thailand, the United Arab Emirates, the United Kingdom, the United States, Gibraltar, Liechtenstein, Norway and the European Union countries. Anywhere else, describe_readiness and describe_account carry a countryNote with this sentence, the country named:

Card payments are not available for businesses registered in your country yet. PayPal, bank transfer and cash on delivery are coming soon; until one of them opens, this store cannot take payment at checkout. Details: https://formahand.com/docs/payments

Related: orders for what a paid order looks like, environments §3 for Live and Test, billing §3 for the plan rate Formahand charges, agents for the tools.

1. How a payment reaches your order

Whichever provider takes the money, the order ends up carrying the same three things:

On the orderWhat it holds
paymentProviderwhich processor took this payment: stripe, paypal or manual. It is recorded per order, so an order placed before you switched processors is still refunded and cancelled through the processor that actually took it
the checkout idwhat the stock was reserved under: a Stripe Checkout Session id, a PayPal order id, or man_<uuid> for a bank transfer / cash-on-delivery order
the payment idthe settled payment: a Stripe PaymentIntent id, a PayPal capture id, or manual:<orderId>

Every provider reports the same five outcomes back to your store — paid, payment_failed, expired, refunded and dispute — and each report carries an id that is unique per delivery, so a processor that retries a webhook (they all do) never pays, refunds or releases stock twice. Mark as paid on a manual order goes through the very same path, which is why the confirmation email, the customer roll-up and the order.paid event happen exactly once there too, and why a second click answers 409.

Promotion codes are a card-payments feature. On a store whose active provider is PayPal or bank transfer, creating a discount code is refused with 409 Promotion codes need Stripe…, in the dashboard and through upsert_discount alike.

3. Routes

RoutePurpose
POST /api/checkoutquotes the cart, then dispatches to the active provider. Storefront forms: redirect → 303 to the processor; instructions → 303 to https://<store>/checkout/instructions?order=<orderId>. Allowed-origin JSON callers get { url } or { instructionsUrl, orderId }
GET /api/payments/connectthe card account's status refresh plus payments: { provider, status, missing, config, secretsSet, configured, providers } and the same paypal object GET /api/payments/paypal/connect answers, so the Payments tab loads in one call. For a non-card provider connect.status / connect.webhookConfigured mirror that provider's readiness so the home checklist keeps working; the card account's own state is under stripe
POST /api/payments/connectopens or refreshes the card-payments account; also makes stripe the active provider
PUT /api/payments/providerchoose a processor and save its settings: { storeId?, provider, config, secrets }, where config and secrets are flat key/value objects for that provider's own settings (clientId, environment, webhookId, brandName, instructions; clientSecret under secrets). A blank secret keeps the stored one. Session or a settings:write token
POST /api/payments/provider/testPayPal Test connection: asks PayPal for a client-credentials token with the credentials you entered
POST /api/payments/paypal/connect(Coming soon in the dashboard: PayPal for Platforms is sales-gated, so the card reads "Coming soon" until Formahand is approved; the routes below work as soon as the partner credentials exist.) PayPal Connect: creates the partner referral for this store and answers { actionUrl }, the PayPal page you sign up on. Owner, same origin, session only; 503 where Formahand has no partner app for this environment
GET /api/payments/paypal/connectthat store's PayPal Connect state (the paypal object below), refreshed from PayPal
GET /api/payments/paypal/onboarded?store=&mode=where PayPal returns the seller: records the connection, makes PayPal the active processor and lands on /dashboard#settings/payments/other/paypal
POST /api/paypal/partner-webhookthe partner app's own webhook: seller onboarding, revoked consent and the order events of every partner-connected store
GET /api/payments/paypal/return?store=&token=captures the approved PayPal order, records paid, redirects to /checkout/success?session_id=<PayPal order id>; a cancelled or unapproved return goes back to the cart
POST /api/paypal/webhook?store=verifies with PayPal's verify-webhook-signature endpoint using that store's credentials and webhook id, then records the events
POST /api/order-actionsmark-paid (manual orders), cancel (expires the checkout through the order's own provider), refund (dispatches to the order's own provider)
storefront GET /checkout/instructions?order=the buyer-facing instructions page (StorefrontMessage "payment-instructions", optional StorefrontTheme.PaymentInstructions, neutral fallback through ctx.ui)

4. The three providers

Formahand Payments — cards (platform-connect)

Onboarding and account management are embedded: you finish setup, read your balance, refund a payment and edit your business details inside Formahand. See "Embedded Stripe" (§4c) for the screens.

Behind that: direct charges on your own connected account, promotion codes, shipping options, a connected-account webhook (/api/stripe/connect-webhook for live stores, /api/stripe/test/connect-webhook for sandboxes — each verifies its own signature and refuses an event whose livemode or account environment does not match its own, environments §3), and refunds that return Formahand's fee in proportion.

Card payments report ready once Stripe has activated the card_payments capability on your account. A store still shows not-ready where Formahand has not finished installing its own webhook credentials for that environment — nothing you can fix from your side, and the dashboard says so rather than pretending.

A late event for a store that has since switched processors still reaches its orders: the lookup does not require the store to be ready any more, so an old refund or dispute is never lost because you changed provider.

Bank transfer / cash on delivery (built-in)

Settings: instructions (up to 2000 characters). The provider reports ready as soon as instructions exist.

Order lifecycle:

  1. The buyer submits the storefront checkout form. The stock is reserved under man_<uuid> with provider: "manual" (a 7-day expiry, informational) and your store creates a pending order with the next order number.
  2. The buyer is redirected to /checkout/instructions?order=<orderId> (the reservation's checkout id also resolves, for stores whose storefront predates orderId). The page shows the order number as the payment reference, the total and your instructions; the cart cookie is cleared. Cancelled orders and other stores' orders are 404.
  3. The order appears in Orders as Awaiting payment · Bank transfer. You click Mark as paid (POST /api/order-actions { action: "mark-paid" }, session or orders:write). Your store records a paid event with the event id order.mark-paid:<orderId> and the payment id manual:<orderId> through the same path as a processor webhook, so paid_at, the reservation's paid status, the customer roll-up and the order.paid event happen exactly once; a second click is a 409.
  4. If the buyer never pays, Cancel order releases the stock, once, exactly as cancelling an unpaid card order does.
  5. Refunds are record-only: you return the money yourself and the order records manual-refund:<orderId>:<uuid>.

Not collected at checkout: buyer email, address or shipping choice (the total is the cart subtotal, plus tax where your store charges it — see §6c, which requires a shipping country on a taxing store). Take the rest over your usual channel; a small contact form on the instructions page is the obvious next step.

PayPal Checkout (merchant-keys)

Settings: clientId, clientSecret (secret), environment (sandbox | live), webhookId, brandName. The provider reports ready with a client id and secret; a missing webhook id is listed under missing but does not block, because payments settle on return.

Flow, with the exact PayPal fields used (Orders API v2, Payments API v2, Webhooks):

  • Token: POST {base}/v1/oauth2/token, HTTP Basic client_id:secret, body grant_type=client_credentials → access_token. {base} is https://api-m.sandbox.paypal.com or https://api-m.paypal.com.
  • Create: POST /v2/checkout/orders with headers PayPal-Request-Id and Prefer: return=representation; body intent: "CAPTURE", one purchase unit with reference_id, custom_id (the Formahand store id), amount { currency_code, value, breakdown { item_total, shipping } }, items[] { name, quantity, sku, unit_amount }, and payment_source.paypal.experience_context { brand_name, user_action: "PAY_NOW", shipping_preference: GET_FROM_FILE | NO_SHIPPING, return_url, cancel_url }. The buyer goes to the links[] entry with rel: "payer-action" (or approve). Your store's first shipping rate is applied; PayPal shipping options are not offered yet.
  • Return: GET /api/payments/paypal/return?store=&token=<order id> → POST /v2/checkout/orders/{id}/capture (PayPal-Request-Id: capture:<id>). The response's payer.email_address, payer.name.given_name/surname, payer.phone.phone_number.national_number, purchase_units[0].shipping.name.full_name and .address.{address_line_1, address_line_2, admin_area_2, admin_area_1, postal_code, country_code}, and purchase_units[0].payments.captures[0].{id, status, amount} become the paid event. Issue ORDER_ALREADY_CAPTURED reads the order back with GET /v2/checkout/orders/{id}; ORDER_NOT_APPROVED sends the buyer to the cart.
  • Webhook: POST /api/paypal/webhook?store=<storeId>. Headers paypal-auth-algo, paypal-cert-url, paypal-transmission-id, paypal-transmission-sig, paypal-transmission-time plus your webhook_id and the raw webhook_event are posted to POST /v1/notifications/verify-webhook-signature; only verification_status: "SUCCESS" is trusted. Events: CHECKOUT.ORDER.APPROVED (resource: order; captured immediately, for buyers who never returned), PAYMENT.CAPTURE.COMPLETED (resource: capture; supplementary_data.related_ids.order_id is the checkout id), PAYMENT.CAPTURE.DENIED → payment_failed, PAYMENT.CAPTURE.REFUNDED / REVERSED (resource: refund; the capture id comes from links[rel=up]) → incremental refunded. A custom_id that names another store is rejected.
  • Refund: POST /v2/payments/captures/{capture_id}/refund with amount { currency_code, value } and note_to_payer; the response id, status (COMPLETED / PENDING) and amount are recorded against the order.

Amounts cross the wire as decimal strings (JPY, HUF and TWD have no decimals).

PayPal has not been exercised against a live or sandbox account. The request and response shapes follow PayPal's published reference and are covered by tests with fake responses; the first real sandbox run should confirm the payer-action links, the capture representation and the webhook verification round-trip.

Merchant setup steps

  1. On developer.paypal.com → Apps & Credentials, create a REST app (or open the default one). Copy its Client ID and Secret for Sandbox first, Live once tested.
  2. In Formahand, Payments → PayPal: paste both, choose the environment, click Test connection, then Use PayPal.
  3. Back on the app, add a webhook with the URL shown on the card (https://formahand.com/api/paypal/webhook?store=<storeId>) subscribed to CHECKOUT.ORDER.APPROVED, PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED, PAYMENT.CAPTURE.REFUNDED. Paste the webhook's ID into the card and save.
  4. Place a sandbox order from the storefront with a sandbox buyer account; confirm the order turns Paid · PayPal in Orders and that a refund from Orders appears in the sandbox account.
  5. Switch the environment to Live with the live credentials and a live webhook.

PayPal Connect (partner onboarding)

Connect with PayPal replaces pasting keys: you click one button, sign in at PayPal, and come back connected. Formahand is a PayPal partner (Commerce Platform for Platforms & Marketplaces); your own PayPal business account takes the money, Formahand's fee is split off at capture, and no PayPal credential of yours ever reaches Formahand. If you already pasted your own REST app credentials you keep them: paypal.connection reads own for those stores and partner for connected ones, and everything below applies only to partner.

Where Formahand has no partner app for the environment you are in, the button is hidden and POST /api/payments/paypal/connect answers 503 "PayPal Connect is not available on this deployment yet." One-click onboarding is also unavailable in a sandbox that has no sandbox partner app of its own: a live PayPal app cannot talk to PayPal's sandbox at all, so there is no fallback to a live app — you paste your own sandbox credentials instead.

The flow

  1. Connect. POST /api/payments/paypal/connect { storeId?, country } (owner, same origin) creates a partner referral at PayPal and answers { actionUrl }, the sign-up page. Your store's active processor is not switched yet, so a store already taking card payments keeps taking them while you are away at PayPal.
  2. PayPal. You sign in (or sign up), grant Formahand permission and return to GET /api/payments/paypal/onboarded?store=<id>&mode=<mode> with merchantId (Formahand's tracking id), merchantIdInPayPal, permissionsGranted, consentStatus, isEmailConfirmed, accountStatus and riskStatus. The tracking id must name the store in the URL and match the one recorded, so a link minted for one store can never connect another; anything else is a 400. PayPal's mini-browser flow lands on the same route with the same parameters.
  3. Confirm. The route reads your merchant integration back from PayPal — PayPal, not the query string, is the authority — and records your PayPal merchant id, whether permission was granted (a non-empty oauth_integrations[].oauth_third_party[].scopes), whether your email is confirmed (primary_email_confirmed), whether payments are receivable (payments_receivable) and whether advanced card processing is on (the CUSTOM_CARD_PROCESSING capability, or a *_CUSTOM product, ACTIVE/SUBSCRIBED). PayPal becomes the active processor and you land on /dashboard#settings/payments/other/paypal (303).
  4. Status. GET /api/payments/paypal/connect — and the paypal object on GET /api/payments/connect — re-reads your merchant integration every time the tab opens (nothing is cached) and writes back what changed, so a seller who confirms their email at PayPal an hour later is ready without touching Formahand:
{ "paypal": { "available": true, "connection": "partner", "merchantId": "SELLER123456",
  "permissionsGranted": true, "emailConfirmed": true, "paymentsReceivable": true,
  "acdc": true, "missing": [] } }

connection is "partner", "own" or null; available says Formahand has a partner app for this environment; missing is the plain-language list of what PayPal is still waiting for ("Grant Formahand permission in PayPal", "Confirm the email on your PayPal account", "PayPal is still reviewing your account"). The provider reports ready only with a merchant id and all three flags.

What a partner checkout sends

Everything the merchant-keys flow sends, plus, on the order Formahand's app creates for you:

  • a PayPal-Auth-Assertion header naming your account, and the partner attribution header;
  • purchase_units[0].payee { merchant_id } — the money lands on your own account;
  • a payment_instruction with disbursement_mode: "INSTANT" and Formahand's fee, at the same percentage card payments charge on your plan (billing §3), computed on the cart subtotal exactly as the card fee is.

Captures and refunds carry the auth assertion too. The webhook verification call deliberately does not: the webhook belongs to whoever registered it, not to the seller the event is about.

Webhooks

Formahand's partner app is subscribed at https://formahand.com/api/paypal/partner-webhook (the sandbox app at the same path with ?mode=test) to:

MERCHANT.ONBOARDING.COMPLETED · MERCHANT.PARTNER-CONSENT.REVOKED · CHECKOUT.ORDER.APPROVED · PAYMENT.CAPTURE.COMPLETED · PAYMENT.CAPTURE.DENIED · PAYMENT.CAPTURE.REFUNDED · PAYMENT.CAPTURE.REVERSED

Every delivery is signature-verified with PayPal before anything in it is read. MERCHANT.ONBOARDING.COMPLETED finds your store through the tracking id, records your PayPal merchant id and refreshes the integration — it records readiness only; you still choose PayPal in Payments. MERCHANT.PARTNER-CONSENT.REVOKED marks permission withdrawn, which puts a PayPal store back to pending so checkout stops. The order events resolve the store from custom_id (the store id every Formahand order carries) and are mapped exactly as the per-merchant webhook maps them.

PayPal Connect has not been exercised against a live or sandbox partner account. The request shapes follow PayPal's published reference (partner referrals v2, merchant integrations v1) and are covered by tests with fake responses; the first sandbox run should confirm the referral's action_url, the return parameters and the webhook verification round-trip.

4b. Orders you create yourself

Not every sale starts on the storefront. You take an order over the phone, at a market stall or on an invoice, and want it in Orders with the stock held and a way to be paid. Orders → Create order does exactly that, and POST /api/orders plus the MCP tool create_order are the same thing for an agent (agents "Orders you create yourself").

What your store does

A draft order takes { customer: { email, name?, phone? }, lines: [{ productSlug, variantId?, quantity, unitPriceMinor? }], shipping?: { amountMinor, label } | null, discountMinor?, note?, shippingAddress? } and:

  1. prices every line through the same quote a shopper's checkout uses, so only published products sell, a product with options must name its variant, and short stock is refused before anything is held;
  2. reserves the cart the same way a shopper's cart is reserved — one batch, all lines or none, backorders and variants included — under a checkout id of draft_<uuid>, with provider: "manual";
  3. records what only you know: the customer, the agreed shipping amount and label, a discount, an internal note, the address, and total = subtotal + shipping − discount;
  4. marks the order's source as manual (storefront is the default, so every existing order keeps its meaning) and emits order.updated.

unitPriceMinor is your own price for a line. It is refused from a bearer token (403), so an agent can only sell at the published price.

Per-line quantity is capped at 10 and an order at 20 lines, the same ceilings the storefront cart has.

Collecting the money

Send a payment link (POST /api/order-actions { action: "send-payment-link" }):

  1. opens a card Checkout Session on your connected account with the order's own lines, the agreed shipping as a line of its own, and — when the order is discounted — a one-off amount-off coupon, so the session total equals the order total, which matters because the paid event writes the amount back onto the order. The order id travels with the session as metadata[order_id];
  2. re-keys the order and its reservation lines to the new checkout id. The connected-account webhook finds an order by its checkout id, so paying the link settles this order — no second order, no second reservation — and the ordinary confirmation email follows;
  3. expires the previous link, if you had already sent one, so two buyers can never pay for one reservation;
  4. emails the buyer your store's Payment request template, whose data.paymentUrl is that checkout.

The order keeps paymentProvider: "manual": you are the one collecting, and Mark as paid stays available for money that arrives as cash or a transfer instead. Only an order with source: "manual" that is still pending takes a link; anything else is a 409. Sandboxes work the same way against the test account (environments §3).

In the dashboard

Orders → Create order collects the customer (with a lookup of your existing customers, so an address you already hold is offered rather than duplicated), the products (search, variant, quantity, an optional price of your own), a shipping amount and label, and an internal note, then finishes one of three ways: Create and send payment link, Create and mark paid, or Create as pending. The order's Came from card says Created by: You in the dashboard and the order detail offers Send payment link while it is unpaid.

4c. Embedded Stripe (default)

You finish payouts, read your balance, refund a payment and edit your business details inside Formahand. There is no hop to another site and no second login.

Two kinds of connected account

Formahand records which surface your account uses.

KindOpened withWhere you work
none (the default for every new account)Accounts v2 dashboard: "none"Payments, in embedded components
expressan older account with the processor's own Express dashboardthe processor's hosted pages; the dashboard says "opens on Stripe"

Accounts opened before embedded screens existed are treated as express until the status refresh (GET /api/payments/connect, which re-reads your account every time the Payments tab opens) corrects them. So an older account that was in fact opened with dashboard: "none" picks up the embedded screens on your next visit, with nothing for you to do.

Account creation

Opening the account posts:

{
  "contact_email": "<owner's email>",
  "display_name": "<store name>",
  "defaults": { "responsibilities": { "fees_collector": "stripe", "losses_collector": "stripe" } },
  "dashboard": "none",
  "identity": { "country": "<iso-2, lower case>" },
  "configuration": { "merchant": { "capabilities": { "card_payments": { "requested": true } } } },
  "include": ["configuration.merchant", "identity", "requirements"]
}

dashboard is the only thing that changes between the two kinds: "none" by default, "express" when you asked for the hosted flow (POST /api/payments/connect { hosted: true }, which the dashboard sends when the address bar carries ?hosted=1). Fees and losses stay with the processor exactly as before, so nothing about pricing, risk or payouts moves.

Formahand then prefills the website, what you sell, the retail category (mcc 5399) and the statement descriptor — the name a buyer sees on their card statement. The descriptor comes from your own domain: www. dropped, upper-cased, letters, digits, dots and spaces, 5–22 characters, so example.com becomes EXAMPLE.COM. It falls back to your store's name only when you have no custom domain. The processor checks the descriptor against the business name and the website on the account and refuses one that resembles neither ("Statement descriptor isn't similar to the business name or website"), which a store trading under a name unlike its domain would otherwise hit. The prefill runs again just before every account session, so an account carrying an older, refused descriptor is corrected the next time you open Payments.

Account sessions

POST /api/payments/account-session (session-only, owner or admin; agents "Never possible with a token") creates the session for your account in the environment you are working in — live keys for the live store, test keys for the sandbox (environments §3) — and answers { clientSecret, expiresAt, publishableKey, accountId, mode }. It asks for exactly the components Formahand renders:

ComponentFeatures enabled
account_onboardingexternal_account_collection
account_managementexternal_account_collection
notification_bannerexternal_account_collection
payoutsedit_payout_schedule, instant_payouts, standard_payouts, external_account_collection
payouts_list—
balancesedit_payout_schedule, instant_payouts, standard_payouts, external_account_collection
paymentscapture_payments, refund_management, dispute_management; destination_on_behalf_of_charge_management off (charges are direct)
payment_detailsthe same four
documents—

The processor enforces that list on its side, so it is the real permission boundary: the browser cannot ask for a component or a feature that is not on it. A feature that appears on more than one component carries the same value in one session, which the processor requires. tax_settings and tax_registrations are enabled too (both take no features): they are how you switch automatic tax on and add a registration per region without leaving Formahand — see §6c.

Sessions are short-lived. The embedded UI asks for a new client secret when one expires and the route mints another; nothing in the screen resets.

The dashboard

The embedded screens load in the browser only, styled from Formahand's own design tokens so they match the rest of the dashboard, and each has its own loading, error and "not set up yet" state in plain language.

Payments opens as a hub of tiles: Card payments, Payouts, Transactions, Other payments, Protection and extras, Tax and Account and documents, each opening one part at #settings/payments/<part>. For a none account:

  • the notification banner at the top of the hub (it renders nothing when the processor needs nothing);
  • a new store that takes no payments yet sees Card payments first with "Start here" in the setup tone; set-up is its own screen, "Step N of 3" at #settings/payments/setup (country, then /setup/business, then the secure form at /setup/verify, with the remaining requirements listed under it); leaving it re-reads the account and updates the page;
  • active → Payouts (balances and payouts), Transactions (payments, refunds and disputes) and Account and documents (account management and tax documents).

For an express account nothing changes: the country picker, "Set up payouts" / "Continue setup" and "Update payout details" still open the processor's hosted page, each marked "Opens a secure page".

The Home foundation step "Set up payouts", the onboarding wizard's row and every "set up payments" link open the set-up steps (#settings/payments/setup) inside Formahand, never a processor URL.

Where the processor's emails land

The processor writes to connected accounts itself — a document it needs, a bank account to confirm, a risk review — and its messages link to the page of your Formahand dashboard that answers them:

ScreenLink
Notifications banner (the top of the hub)https://formahand.com/dashboard#settings/payments
Account management…#settings/payments/account (the Account and documents part)
Payouts…#settings/payments/payouts (the Payouts part)
Payments…#settings/payments/payments (lands on the Transactions part)
Balances…#settings/payments/balances (lands on the Payouts part)
Documents…#settings/payments/documents (lands on the Account and documents part)

The fragment is #<module>/<tab>/<part> — the same shape wall steps have always used (billing). The shell selects the module, Settings selects the tab, and the Payments hub opens the part; an older name (payments, balances, overview, documents) is rewritten in place to the part it became. A second link followed while the dashboard is already open moves to it too. Formahand's own notices link deeper still: details still owed go to #settings/payments/setup/verify, tax notices to #settings/payments/tax. A fragment never reaches a server, so all of this happens in your browser.

A signed-out visitor lands on the sign-in card. The fragment travels in the sign-in callback URL and is kept in the tab's session storage, so if the redirect drops it you still arrive on the screen you clicked. The processor also appends stripe_account_id to the link; Formahand ignores it and shows the store you are signed in to, which is the account you are allowed to see.

Starting over

POST /api/payments/connect { reset: true } (owner only, same-origin, session-only) opens a new connected account for your store and points the store at it. It is refused with 409 once the current account has taken a payment, and a processor error while checking counts as "has payments" — failing closed, because the alternative is abandoning an account with money behind it. In the dashboard it is the Start over with a new account button, shown only on an express account, behind a confirmation that says plainly what it does.

The caveat is the point: the old account is left behind, not migrated. Anything already entered on it — the business details, the bank account, the verification documents — is not carried over, and the processor keeps the old account. Use it in the sandbox to try the embedded flow, or if you never finished the hosted one.

When the embedded screens are unavailable

The embedded screens need a public display key for the environment you are in. Where Formahand has not installed one, POST /api/payments/account-session answers 503 with missingPublishableKey, the dashboard says "Payments are not finished on this deployment yet", and the hosted flow keeps working.

4d. Payout profile

The processor's embedded form asks you twenty questions, and Formahand already knows the answer to most of them. The payout profile is the handful you (or your agent) answer first, in Formahand's own words, so the secure form opens mostly done and only asks for what nobody else can give: identity, bank account and terms.

Two front doors share it: GET/PUT /api/payments/profile and the get_payout_profile / set_payout_profile MCP tools.

The shape

{
  businessType: "individual" | "company",
  businessName?: string,          // company only, the registered name
  category: string,               // one of the categories below
  websiteMode: "store" | "other" | "none",
  websiteUrl?: string,            // required when websiteMode is "other"
  productDescription: string,     // ≥ 40 characters when websiteMode is "none"
  chargeTiming: "at_checkout" | "on_ship" | "deposit",
  supportEmail: string,
  supportPhone?: string,
  representativeName?: string,
  representativeEmail?: string,
}

Categories

Twenty-one plain-language names, each mapped to one merchant category code from the processor's published list of supported codes: clothing 5651, shoes 5661, jewellery 5944, beauty 5977, home & furniture 5712, food & grocery 5411, bakery 5462, coffee & tea 5499, health & supplements 5499, digital goods & software 5817, music/film/games 5815, books 5942, art & crafts 5970, sporting goods 5941, toys & hobbies 5945, electronics 5732, pet supplies 5995, education 8299, services & consulting 7392, events & tickets 7922, general retail 5399 (also the fallback).

No restricted code is offered — pharmacy (5912), drugs (5122), medical supplies (5047), medical and counselling services, gambling, charity, political and religious organisations. A platform that is not approved for them gets mcc_invalid back, so a picker that offered them would only produce a refusal. Health and supplements therefore map to 5499, the nearest unrestricted code. The processor may also override a category after review; further attempts to change it then error, which is fine — the prefill is best effort.

The defaults

Formahand writes a first draft from what it already knows about your store, so no field is ever blank and no field is ever a placeholder (a reviewer refuses both):

  • category from your onboarding category, matched on words, else general retail;
  • websiteMode store, with your live custom domain when one is active and the free <slug>.store-on-formahand.app address otherwise;
  • productDescription — "<Store> sells <category words> online at <url>. Customers are charged at checkout and orders ship within a few days.";
  • supportEmail, representativeName and representativeEmail from your owner account.

What is sent to the processor

Only fields the account API documents:

FieldValue
contact_emailsupportEmail
defaults.profile.business_urlyour store URL (store), websiteUrl (other), omitted entirely (none)
defaults.profile.doing_business_asyour store's name
defaults.profile.product_descriptionyour words, plus the charge sentence
configuration.merchant.mccthe category's code
configuration.merchant.support.email / .phone / .urlthe support contact; .url only when there is a website
identity.entity_typeindividual or company
identity.business_details.registered_namebusinessName, company only
identity.individual.{given_name,surname,email}the representative, individual only

The statement descriptor (§4c) is added on top, and it all goes in two requests: the business details first (contact_email, defaults, configuration), then identity on its own. An identity the processor refuses — an entity type it has already fixed after you answered the form, say — therefore never costs you the website, category and support details. The outcome comes back as applyError on the profile: the dashboard says "Saved. One detail could not be sent ahead (…); the secure form asks for it instead", and set_payout_profile hands the same text to your agent. Note that the trading name lives at defaults.profile.doing_business_as: identity.business_details has no doing_business_as in v2, and no product_description either.

chargeTiming becomes the last sentence of the description, in the wording the review expects — "Customers are charged at checkout." / "…when the order ships." / "…a deposit at checkout, the balance on delivery." — replacing any charge sentence the draft already ended with, so editing the chip never duplicates it. With websiteMode: "none" the description also gains "<Store> does not have a public website yet; products are sold directly to customers.", because the description is then the only thing the review has to go on.

Warnings

The profile tells you what would make the review go badly, before you submit. It never refuses; the profile saves either way.

idWhen
coming_soonwebsiteMode: "store" and your storefront still answers the Coming soon page — a site under construction is usually refused. Open the store first (Home → Open your store), or choose "No website yet"
free_addresswebsiteMode: "store" on the free address — fine for the review, but add products with photos and prices first
no_productswebsiteMode: "store" with nothing active to sell
description_shortwebsiteMode: "none" and under 40 characters
website_missingwebsiteMode: "other" with no address
business_namea company with no registered name

The launch mode and the catalog behind those warnings come from your store's own readiness — whether the storefront is open, whether a custom domain is live, how many products are active.

When the profile reaches your account

Answering the questions never switches a store that takes PayPal or bank transfer over to card payments behind its back: the profile is saved without activating anything.

  • An account already exists: PUT /api/payments/profile sends it immediately, re-reads the account and answers with the fresh leftover.
  • No account yet: POST /api/payments/connect reads it back and sends it in the same prefill that opens the account.

Both are best effort. A refused prefill never blocks onboarding and never costs you your answers.

Routes and tools

GET /api/payments/profile?storeId= → { profile, defaults, warnings, applied, leftover }, where leftover is a summary of what the account still owes. PUT takes { storeId?, profile }, saves, applies and answers the same shape.

Both carry a settings scope rather than being session-only: nothing in the profile is a credential, an identity document or a bank account — it is what you sell, where, and who answers support mail, which an agent that has read your catalog and your domains answers better than you typing into a form. The tools are get_payout_profile {} and set_payout_profile { profile }; the write answers with handoff (/dashboard#settings/payments/setup) so an agent hands over instead of claiming payouts are set up. Readiness carries it as the payout_profile requirement of checkout, severity recommended, tool set_payout_profile — it never holds checkout back, because a merchant who types everything on the secure form gets paid just the same.

In the dashboard

Set-up is its own screen, separate from the Payments overview: Set up Formahand Payments, "Step N of 3" with a progress bar, one card per step and Back / Next at the bottom. Step 1 is the business country. Step 2, Tell us about your business, appears while there is no account yet or the account still owes a business-profile, category or entity-type answer: business type as two chips, what you sell as a select, website as three radios ("My store address: <url>" / "Another website" + input / "No website yet"), the product description prefilled and editable, the charge-timing chips, support email and phone, and the representative. Warnings render inline. Continue to the secure form saves and opens step 3; Skip, fill it in on the form goes straight there. Step 3 is the embedded onboarding (identity, bank, terms). An account session is only opened in step 3 or for a ready account, never on the overview, because the processor stops accepting profile writes once a session exists.

Account notices

Formahand emails you — the store owner — about your card-payments account. This is Formahand writing to its own customer, never your store writing to shoppers.

ChangeEmail
in review or waiting → ready"{store} can take card payments now"
in review → details needed, or a new item asked for"{store}: a few more details for Formahand Payments", with the items
ready → details needed"Action needed: card payments paused for {store}"

The first look at an account only records its state; items shrinking while you fill the form in send nothing; the same state never mails twice; sandbox accounts never mail. Your account is looked at when you open Payments and, every ten minutes, in the background (live accounts that are not ready, and ready ones once a day, oldest first, fifteen at a time), which also turns your storefront's buy buttons on or off — so a merchant who closed the tab is still switched on.

Formahand also emails you on a failed payout ("A payout to your bank failed", link to Payouts) and on a dispute ("A buyer disputed a payment", with the respond-by date, link to Payments) — live mode only, and never twice for a redelivered event.

4e. Payment features and what you control

Your card-payments account can do more than take a card. This section is the honest list: what each feature does, what it costs you at your payment provider, and — the part that matters — whether it is yours to decide or theirs. A switch you can see but cannot really move is worse than no switch, so anything your provider decides on your behalf is shown here as status and never as a control.

None of these costs are Formahand's. Formahand's percentage is your plan rate and does not move when you switch one of these on (billing); everything below comes out of what your provider pays you.

The list

FeatureWhat it doesWhat it costs youWho decides
Automatic taxSales tax, VAT and GST worked out at checkout from where the buyer is and where you are registered0.5% of each order, and only in places you have added a registration forYou
Stronger card authenticationAsks the buyer's bank to check it is really them before the payment goes throughNothing. It is included with card paymentsYou
Fraud screeningEvery card payment is scored, and risky ones are blocked or held for review before the money movesNothing. It runs on every card paymentYour provider
Local currenciesShows a buyer abroad a price in their own currencyNothing for you. The converted price carries a conversion charge the buyer pays, at your provider's rate for your country, and you are still paid in your own currencyYou
Instant payoutsSends your balance to your debit card in minutes instead of waiting for the bankUp to 1.5% of each instant payout, at your provider's rate for your country, with a minimum of about 0.50. Waiting costs nothingYou
Payment methodsWhich ways to pay a buyer is offered: cards, phone wallets and local methodsCards and wallets are your normal card rate. Pay-later (Klarna, Afterpay/Clearpay, Affirm) is coming soon and costs nothing while it isYou, except the coming-soon ones
Dispute coverCover for the money and the fee when a buyer's bank reverses a paymentComing soon, and nothing to pay while it isComing soon

Every state on that screen is read from your account when you open it, not from a copy Formahand keeps. A row can never say "on" while your next checkout behaves as if it were off.

Automatic tax

Two things have to be true before a single order is taxed, and the screen tells you which one is missing:

  1. Tax is set up on your payments account — a head office address and a default product category. Until it is, tax is not calculated at all.
  2. You have added a registration for every place you collect tax in. Where there is no registration, orders are taxed at nothing — not refused, not flagged to the buyer, simply untaxed. This is the one that catches people out.

Both are done inside Formahand, on your account's own screens under Payments. Registrations are yours to keep up to date: you must register with a tax authority yourself, and then add that registration here. Your obligations change as you grow — most places make you register once your sales there pass a threshold — and your provider tells the account holder about thresholds by email, which on a Formahand account is us and not you. So treat it as yours to watch: when you start selling somewhere new in volume, check whether you now have to register there, and add it here when you do.

Switching tax off in Formahand stops the calculation; it does not remove your registrations.

Tax registrations

A registration is not a setting. It is a statement about your business — this business is registered to collect tax in this place — and Formahand does not check it with any tax authority, and neither does your payment provider. Your provider simply works tax out wherever a registration says to, and taxes everywhere else at nothing. So the statement carries your name, and this is what that means in practice.

What you can add. A country, a state or province where the country has them, and the kind of registration. The United States is per state, and the kind is nearly always state sales tax; Canada has the federal registration, the simplified scheme and a provincial one; the EU has the ordinary national registration and the One-Stop Shop schemes; most other countries have exactly one kind. Some countries also have local registrations — amusement, parking, resort and the like — which need a local jurisdiction code; those are not offered here, because a registration Formahand could accept but your provider would then ignore is the same as no registration at all, and you would not know.

When it starts. Today, or a date in the future. A registration never starts in the past: tax is worked out from the day it starts, and orders you have already taken are not re-taxed. If you owe tax on earlier orders, that is between you and your accountant.

Ending one. Registrations are expired, never deleted — here or at your provider. From the date it ends, orders going there are taxed at nothing; everything already sold stays as it was, and the record that you once claimed it stays too, which is exactly what a tax authority would ask about.

What you are agreeing to

Before any registration is added, you tick a box carrying this sentence, whole:

You confirm that your store is registered to collect tax in jurisdiction as of date, that the details are accurate, and that you are responsible for tax you collect or fail to collect. Formahand calculates tax from what you tell it and does not check registrations with tax authorities.

Every add records who agreed to it, when, from what address and which version of the terms was in force, and keeps a fingerprint of the exact words — so a later rewording can never be passed off as what you actually read.

An agent's registration binds you. An agent or an installed app can add a registration, and when it does, the confirmation above is yours: you issued the token, the claim is recorded against your store with the agent named as the actor, and you carry the consequences of tax collected or not collected. You are emailed every time an agent or an app adds or ends one — never when you do it yourself, because you were there.

When a registration is refused

Your provider may reject a registration later: an id it no longer holds, a status that has flipped away from active. When that happens:

  1. the claim is marked refused on your registrations list, and
  2. tax is paused for the whole store. Checkout carries on and orders are taxed at nothing, exactly as they are when your account cannot calculate at all — a lost sale is worse than an untaxed one — and
  3. you get an email and a notice on Payments saying Tax collection paused: registration &lt;x&gt; was refused; confirm or expire it.

Twice in ninety days and tax is locked. No token, agent or app can switch tax back on; only you, signed in, re-reading the whole confirmation under Payments. Switching tax off is never locked: stopping a wrong tax being charged must never need a ceremony.

Three times and somebody at Formahand looks at your store. You are told that repeated incorrect tax information breaches the terms you accepted and may lead to your store being suspended. Nothing is suspended automatically: it is a decision a person takes, on the terms, after reading your registrations.

Stronger card authentication

Three choices:

  • Leave it to the bank (the default) — the buyer's bank and the law decide. In Europe and the UK that already means most payments are checked; elsewhere most are not. This is the right answer for nearly every store.
  • Every card payment — every buyer's bank is asked to check them, with a preference for the quiet kind of check that usually shows the buyer nothing.
  • Only above an amount — the same, but only once the goods come to more than the amount you set.

What you gain is fewer disputes: when the buyer's bank has confirmed them, the bank generally carries a fraudulent chargeback rather than you. What you lose is a few buyers who give up at their bank's screen. There is no way to ask for less checking than the rules require, from Formahand or from anyone — if your provider or the law says a payment must be checked, it is checked.

Fraud screening

This one is status, not a switch. Every card payment on your account is scored before the money moves, and the rules behind that scoring belong to your payment provider, not to your store — there is no way to write your own from here, and a store-by-store allow list or block list is not offered either. What you get instead is the decision on each payment, shown beside it under Payments, and a notice when something needs you (below).

Local currencies

It is off until you turn it on (Settings → Payments, or an agent with set_payment_feature { feature: "local_currencies", enabled: true }). On, a buyer in another country sees your total in their own currency on the payment page and pays in it. Your prices stay in your store's one currency, and your storefront and cart show that currency; the conversion happens only on the payment page. Your payouts and your books stay in your currency; the conversion charge is inside the buyer's converted price. It only applies where your price currency is the currency your account settles in, and it does nothing for buyers at home.

Instant payouts

Your balance normally leaves on a schedule — Every day, Every week, Every month or When you ask — and that schedule is the switch on this card. Leaving it on a schedule means there is rarely anything waiting to send instantly, so a store that wants instant payouts sets it to When you ask and sends the balance across when it wants the money.

Send now is offered when a card you are paid into can take an instant payout at all. Not every bank account can; a debit card usually can. The fee is your provider's, listed above, and Formahand adds nothing to it.

Four things guard it, because it is the one control here that moves money out of your account:

  • Only you or an admin. A signed-in helper on your team cannot send the balance, and an agent needs payments:write, which only an owner or an admin can issue.
  • A repeat is the same request. The same amount asked for twice inside ten minutes is one payout, not two, so a timed-out agent or a double-clicked button cannot send it twice.
  • Five a day. Per store and per environment. Your balance is not capped; the number of trips is.
  • You are told. Every instant payout emails you what left and whether an agent asked for it, so a connection you have forgotten about cannot quietly empty the balance.

A payout leaves in the currency your account is paid in. Asking for another currency is refused rather than converted.

Payment methods

Each way to pay is a row with its own rate. Cards and phone wallets are your normal card rate.

Pay-later is coming soon. Klarna, Afterpay/Clearpay and Affirm are marked Coming soon and have no switch. Nothing asks your payments account for them, so there is nothing to turn on and nothing being charged for them; when they open, the rate per order will be on each row, because pay-later costs noticeably more than a card and is worth switching on deliberately rather than by default.

Other rows can also be without a switch: either your account cannot offer that method at all in your country, or it is set for every Formahand store and is not yours to change. Each says which.

Dispute cover

Coming soon. It is not offered on accounts opened through Formahand yet, and nothing is charged for it in the meantime. Until it arrives, a disputed payment comes out of your balance while it is open, and the dispute itself carries a fee of about US$15 — the same figure in your own currency, and it varies by country — whether you win or lose. Stronger card authentication is the switch that actually reduces how often it happens.

Notices

You cannot log into your payments account, so anything it would have told you comes to you through Formahand instead. Each of these arrives three ways at once: an email to you (only when you have to act on it), a notice on Payments, and an event on your store's own log, so a flow, a subscription or an agent can react without anyone wiring a special case.

What happenedEventEmail
Your account needs details, a deadline is near, payouts are paused, or the account was disconnectedpayments.account_changedYes
A dispute opened, money moved for it, or it closedpayments.disputeOn opening
A card issuer flagged a payment as fraud, or a payment is held for reviewpayments.fraud_warningOn a fraud warning
A payout to your bank failedpayments.payout_failedYes
Your tax setup changedpayments.tax_noticeNo
A tax registration was added, or endedpayments.tax_registration_added, payments.tax_registration_expiredOnly when an agent or an app did it
A registration was refused and tax was pausedpayments.tax_noticeYes

Each is applied once, however many times your provider sends it, so you never get the same warning twice. Your sandbox records the same events, and its emails arrive marked [Test] like every other sandbox message.

Routes and tools

GET /api/payments/features?storeId= answers { ready, mode, features: [...], handoff } with a settings:read scope. PUT changes exactly one feature and is deliberately two calls: without confirm: true it answers { applied: false, consequence } — the sentence saying what the change does and what it costs — and touches nothing. It carries **payments:write**, a scope nothing else implies, because two of these switches change what every order costs you.

GET /api/payments/payouts?storeId= reads your schedule, what is ready to send and whether an instant payout is possible; POST sends one, also on payments:write, because moving money is never a settings write. A signed-in caller must be the owner or an admin for both PUT /api/payments/features and POST /api/payments/payouts; POST answers 429 past five payouts in a day.

The agent tools are describe_payment_features {}, set_payment_feature { feature, enabled, confirm }, request_instant_payout { amountMinor? } and list_payment_notices { limit }. An agent must read the consequence sentence back to you before calling again with confirm: true; that is the whole reason the tool is two calls.

GET /api/payments/tax-registrations?storeId= lists your registrations with the attestation behind each one (settings:read); POST adds, ends or re-confirms one and carries **payments:write**. It is two calls in the same way: without confirm: true the answer is the attestation, the effective date and the fee, and nothing is touched. The agent tools are list_tax_registrations {}, add_tax_registration { country, state?, type, activeFrom?, confirm, attested } and expire_tax_registration { registrationId, expiresAt?, confirm }. Adding and ending are also emailed to you whenever an agent or an app did it.

Every add and every end is on your store's own log as payments.tax_registration_added and payments.tax_registration_expired, with the agent, app or person who did it as the actor — so a flow or a subscription can watch for them.

Everything applies per environment: your sandbox has its own account and its own answers, so you can try a switch there first.

What is deliberately not here

No fraud rules of your own and no per-store allow or block lists — those belong to your payment provider and cannot be set per store. No way to ask for less card authentication than the rules require. No Formahand fee on any of it: every number above is your provider's, taken out of what they pay you.

Coming soon, shown on the screen as such rather than hidden: dispute cover, and the pay-later ways to pay. Both are marked coming_soon in describe_payment_features too, so an agent tells you "coming soon" and not "your account cannot".

5. Dashboard

Payments has its own sidebar group, apart from Checkout. The overview is three cards:

  • Formahand Payments — a status badge (Active / In review / Setup required), Set up / Complete setup opening the set-up screen at the first open step, "Still needed" while requirements are owed, and, for a ready account, the embedded Overview / Payouts / Payments / Account / Documents tabs. An express account keeps "Update payout details", which opens a secure page.
  • Other ways to pay — PayPal and Bank transfer / cash on delivery as rows. A "Coming soon" row does not open; an available or active one opens its own sub-page with the credential form or the instructions and a live preview.
  • Payment details — Business country (which reopens set-up step 1), Business details (which reopens step 2 while the account still owes them), Tax (§6c) and Fees.

Until the business country is confirmed, the page is set-up step 1 only. You may switch processor at any time; existing orders keep their own provider for refunds and cancellation.

Orders labels non-card payments (Paid · PayPal, Awaiting payment for pending manual orders) and shows Mark as paid on pending manual orders.

6. Checkout settings

What the payment page asks a shopper for, per store, and who on your staff hears about the result. The shapes, the validation and the processor parameters are worked out in one place, so the storefront, the checkout route, the API routes, the agent tools and the dashboard cannot drift.

The settings

FieldValuesDefaultWhat it does
phonehidden | optional | requiredoptionalCollect the buyer's phone number
companyhidden | optional | requiredhiddenA company name, kept on the order as customerCompany
addressLine2optional | hiddenoptionalApartment, suite, unit
orderNotebooleanfalseA free-text "Note for the seller", ≤ 500 characters, kept on the order as customerNote
marketingOptInoff | unchecked | checkedoffA marketing consent box
termsLinkbooleanfalseA tick beside a link to your own Terms page
embeddedbooleantrueWhere the shopper pays: your own /checkout page, or the processor's hosted page (§6b)
tippingfalsefalseReserved; a true is refused with "coming later"

Notification settings: newOrderEmails and lowStockEmails are up to five verified addresses each.

What each setting becomes on the payment page

SettingParameters sent
phone: optional / requiredphone_number_collection[enabled]=true
phone: hiddennothing
company: optional / requiredcustom_fields[n][key]=company, [label][type]=custom, [label][custom]=Company, [type]=text, [optional]=true|false, [text][maximum_length]=100
orderNote: truecustom_fields[n][key]=order_note, [label][custom]=Note for the seller, [type]=text, [optional]=true, [text][maximum_length]=255 — only when the storefront form did not already collect it
marketingOptIn: unchecked / checkedconsent_collection[promotions]=auto
termsLink: trueconsent_collection[terms_of_service]=required plus custom_text[terms_of_service_acceptance][message]=I agree to the [Terms of sale](<your Terms page>)
addressLine2, tippingnothing

Coming back, the completed session carries custom_fields[].text.value for the two text fields and consent.promotions (opt_in / opt_out), which become customerCompany, customerNote and marketingOptIn on the order.

Where the processor cannot do what was asked, plainly:

  • No optional phone. Once phone_number_collection is on, the hosted page requires it. optional and required therefore send the same parameter; the dashboard says so beside the control.
  • No pre-ticked consent. The promotions box is never pre-ticked, and the processor decides whether it may be shown at all (it depends on the buyer's country and yours, and is switched off where local law forbids it). checked is honoured only on a front end you build yourself.
  • Address line 2 cannot be hidden. The hosted address form always shows it. hidden is stored and published on the checkout quote, so a custom front end that builds its own address form honours it.
  • A terms tick needs a Terms page. With no Terms page in your store, termsLink sends nothing rather than creating a session the processor would refuse. The processor also needs a terms-of-service URL in the connected account's public business details before it will render the tick.
  • A terms tick the processor refuses stops the sale. The marketing consent box is a nicety, so a refusal there is retried once without it; the terms tick is an agreement you said you need, so dropping it and taking the money anyway is not an option. The checkout answers 503, the shopper is asked to try again, the refusal keeps its own type all the way out instead of being flattened into a generic failure, and you are emailed the same day — once a day per environment, with the day claimed by a single write so that refusals racing each other still send one email, and a store with no account for that environment still gets the gate — because a shop that has quietly stopped taking orders is the worst thing to leave unsaid. The two ways out are in that email: switch the terms tick on in your payments account, or switch it off in Checkout settings.
  • Tipping is not built. The field exists so the shape is stable; the dashboard shows it disabled and a true is a 400.
  • PayPal collects the phone and the address from the payer's own account (shipping_preference) and offers no custom fields or consent boxes, so only orderNote applies there — through the storefront form, which posts the note with the cart. Manual payments collect nothing at all at checkout (see §4); the storefront note is the one field that still reaches the order.

The path a setting travels

  1. You (or an agent) write the row: PUT /api/checkout-settings.
  2. The storefront cart reads orderNote and renders the "Note for the seller" box inside CartCheckoutFields, so every theme gets it without a change.
  3. POST /api/checkout quotes the cart. Your store attaches checkout: { …settings, termsUrl } to the quote answer it was already sending, so no extra call is made per checkout. A store whose storefront predates the block falls back to the defaults.
  4. The route derives what has to be collected, skips the note field when the form already carried one, and hands both to the provider.
  5. The note travels with the stock reservation; your store writes it onto the order it just created, and only if you actually ask for a note — a forged field on a store with the box off writes nothing.
  6. On payment, customerCompany / customerNote are recorded (an empty value never overwrites what the form already stored) and, only when the consent box came back accepted, a pending contact is moved to subscribed. An unsubscribed contact is never re-subscribed.

Staff notifications

Notification settings name addresses that also receive your store's merchant emails. They are added for the two system templates new-order-merchant and low-stock-merchant, so whichever flow sends one — the seeded recipe or a flow you wrote — the extra staff see it. Nothing else is touched: a buyer email, or a merchant template of your own, adds nobody. The morning summary is recorded and not yet sent (automations).

Routes, tools and dashboard

RoutePurpose
GET/PUT /api/checkout-settingsyour checkout settings; session or settings:read / settings:write
GET/PUT /api/notification-settingsthe staff addresses and the summary flag; same scopes

Agent tools: get_checkout_settings, set_checkout_settings, get_notification_settings, set_notification_settings (agents). Both writes replace the whole object, so read first.

Dashboard: Checkout → Options is one row per field with the explanation beside it, including the sentences above about what a processor will and will not do. Email → Sending settings → Notifications holds the two address lists and the summary switch. A shopper's note and company show on the order detail, under the customer.

6b. Where the payment page lives

By default a shopper pays on your own hostname, at https://<your store host>/checkout, in your theme, with your header and footer around the form. The form itself is still the processor's — it renders inside the processor's own frame and card details never touch your store or Formahand — but the address bar, the page and the journey are yours. The processor's own hosted page stays as a fallback and is one setting (or one query parameter) away.

The flow

  1. The cart form posts to POST https://formahand.com/api/checkout exactly as before. Nothing about the cart, the quote, the rules or the reservation changes.
  2. The route decides where the shopper pays (see "Who decides" below) and creates the Checkout Session with ui_mode set and a return_url instead of success_url / cancel_url.
  3. The session comes back with a client secret instead of a URL. The route answers 303 to https://<your store host>/checkout#cs=<client secret>&pk=<publishable key>&acct=<connected account>. The fragment is the point: browsers never send it to a server, so the secret is in no access log, no Referer and no cache key. It is single-use, bound to that one session and worthless without it.
  4. Your store renders /checkout: your chrome, the order summary read from the cart cookie, an empty element for the form, a "Secure checkout" label, the processor notice in small print, and a "Back to cart" link. The HTML contains nothing secret — not the client secret, not the publishable key.
  5. The page's script reads the fragment, clears it from the address bar, then initialises the processor's embedded checkout with that client secret and mounts it into the element. The processor's script is loaded from https://js.stripe.com/v3/; their terms require it to come from their origin, so it is never bundled. Only the payment page loads it.
  6. The shopper pays and is returned to the same https://<your store host>/checkout/success?session_id={CHECKOUT_SESSION_ID} page as before. The order is still recorded by the checkout.session.completed webhook, keyed by session id; the return page only shows it.

The exact Stripe parameters

Everything a hosted session sends, it still sends: line_items[n][price_data][…], metadata[store_id], expires_at, shipping_address_collection[allowed_countries][n], shipping_options[n][shipping_rate_data][…], allow_promotion_codes or discounts[0][coupon], phone_number_collection[enabled], custom_fields[n][…], consent_collection[…], custom_text[terms_of_service_acceptance][message], customer_email, payment_intent_data[application_fee_amount], and the Stripe-Account header for the direct charge. The difference is three parameters:

HostedEmbedded
success_url=https://<host>/checkout/success?session_id={CHECKOUT_SESSION_ID}—
cancel_url=https://<host>/cart (or the product page)—
—ui_mode=embedded
—return_url=https://<host>/checkout/success?session_id={CHECKOUT_SESSION_ID} (or a custom front end's successUrl)

The processor substitutes {CHECKOUT_SESSION_ID} in return_url exactly as it does in success_url. There is no cancel: backing out is the "Back to cart" link on your own page.

**ui_mode has two spellings.** The value was renamed from embedded to embedded_page (and custom to form) in a later API version, and nothing here pins an API version — your account's default decides. Formahand sends embedded and retries once with embedded_page when the value is refused, the same one-retry pattern used when a connected account has not switched on the marketing consent box. Note that embedded / embedded_page is Embedded Checkout, which supports consent_collection, custom_fields and shipping_options; custom / form is the Checkout Form SDK, which does not support consent_collection and is therefore not what is used.

Appearance. Embedded Checkout renders inside a frame served from js.stripe.com, so your CSS cannot reach it — there is no appearance object on it. Its colours, font and radius come from the connected account's own branding settings, which for direct charges is your branding. Everything around the frame — the header, the footer, the summary, the type, the labels — is your theme's. A session-level branding_settings override exists on recent API versions; it is deliberately not sent, because it would silently overrule your own branding and would have to be version-guarded.

Who decides

The route takes the first answer that applies:

  1. **hosted** — a hosted field on the request, or ?hosted=1 on the endpoint. Always the processor's hosted page. It is what the payment page's own fallback posts, and the escape hatch for anything the embedded form cannot do.
  2. The hosted storefront follows your checkout.embedded setting, which arrives on the checkout quote.
  3. A custom front end must ask: embedded: true in the JSON body (headless §4b). Without it the answer is the { url } every existing integration already reads.

Two more things force the hosted page, quietly and on purpose:

  • A store whose storefront predates this page. embedded is answered on the quote by your own store, so the field doubles as a capability flag: an absent embedded is read as false. A store that has not picked up the update keeps redirecting to the hosted page instead of being sent to a /checkout it cannot serve.
  • A deployment with no public display key. The browser cannot mount a form without one, so the checkout falls back to a redirect. The key is per environment (environments §3), so a sandbox mounts with the test key.

Non-card providers are unaffected: PayPal still returns a redirect and manual payments still return { instructionsUrl, orderId }. Payment links for orders you created yourself (§4b) keep the hosted url, because an emailed link has to be a URL.

What stays exactly as it was

Webhooks (keyed by session id), stock reservation and release, checkout rules and discounts, customer groups, checkout settings (custom fields, both consent boxes), digital-only carts (no address, no rate), the release of abandoned checkouts, the sandbox and its test-mode banner, and the /checkout/success view. The only new storefront page is GET /checkout; an empty cart there is a 303 to /cart, and the page is never cached.

If the form cannot load

The page renders a hidden block with the same cart in a form that posts to https://formahand.com/api/checkout with hosted=1. A <noscript> style reveals it when the page cannot run scripts at all, and the page's script reveals it on any failure — a blocked script, a network error, a refused mount. The shopper gets the hosted page with the same cart and the same prices. (That starts a second session; the first expires in 30 minutes and its checkout.session.expired webhook releases the stock, as it does for any abandoned checkout.)

Content-Security-Policy

The storefront sends no CSP header today. If you add one, the payment page needs script-src https://js.stripe.com and frame-src https://js.stripe.com (the processor also uses connect-src https://api.stripe.com and, for wallets, frame-src https://hooks.stripe.com).

6c. Tax

Sales tax, VAT and GST, on every provider. Nothing in Formahand calculates tax: your connected payments account is the one that holds the registrations and is liable for the money, so the processor works every number out and Formahand only decides what to ask it and how to record the answer.

What you set

One set of tax settings per store:

SettingMeaning
enabledOff until you turn it on. While it is off, the tax on every order is 0, the storefront says nothing about tax, and no session, calculation or VAT box is asked for anywhere
pricesIncludeTaxtrue means the price on the product page is what the shopper pays and the tax is inside it (the EU/UK way); false adds it at checkout (the US way). A store that has never saved the row reads a default from its currency — EUR, GBP, AUD, NZD, CHF, SEK, NOK, DKK start inclusive, everything else exclusive — so the first time you open the section it already says the right thing
defaultTaxCodeThe tax category for any product without one of its own; txcd_99999999 (General — tangible goods)
shippingTaxCodeThe category for the shipping charge; txcd_92010001 (Shipping)

A product carries its own taxCode ("" to use the store's). A line resolves in that order: the product's code, else your store's default, else the code the product kind implies — txcd_10000000 for a digital product, txcd_20030000 for a service, txcd_99999999 for a physical one. Codes are opaque strings verified against the processor's own list; the product form and the settings offer a fifteen-entry shortlist, and both fields also take free text so any code in the catalogue works.

Card checkout: the Checkout Session

Direct charges on your connected account, so the Stripe-Account header already names the account that is liable and **no automatic_tax[liability] is sent** — that parameter is only for destination and separate charges (Tax for platforms). The session adds, only when your store charges tax:

ParameterValue
automatic_tax[enabled]true (Checkout Sessions)
tax_id_collection[enabled]true, so a business buyer can enter a VAT / GST number. Never required: tax_id_collection[required] would block a consumer who has none
line_items[n][price_data][tax_behavior]inclusive or exclusive from your setting. The processor requires it unless the account set a default behaviour, and your setting is exactly that answer
line_items[n][price_data][product_data][tax_code]the resolved line code
shipping_options[n][shipping_rate_data][tax_behavior]the same behaviour
shipping_options[n][shipping_rate_data][tax_code]txcd_92010001 unless the rate overrides it

billing_address_collection is left alone: with automatic_tax on, the auto default already collects the minimum a tax calculation needs.

Formahand's fee never touches the tax. The fee is taken on the lines only, before shipping, and with any tax that sits inside those lines taken back out. With tax-exclusive pricing the line prices already are that number; with tax-inclusive pricing they are not, and charging the fee on them would make the same sale cost a Berlin merchant more than a Boston one. It is also the same basis analytics reports revenue on, so the fee and the revenue it is a percentage of always agree.

One approximation, named. A card session on a tax-inclusive store is the one place the base is not exact: the processor works the tax out from an address the buyer has not entered when the session is created, so nothing can subtract it yet, and the fee is taken on the gross subtotal. Every other path knows the tax before the buyer pays and is exact — PayPal and bank transfer price the cart through the tax calculation first, and a payment link for an order you created reads the tax the order already recorded. The effect is bounded by the VAT rate and always in Formahand's favour by a small fraction of the tax, never yours; closing it would mean either an extra tax calculation on every card checkout or a fee refund after the fact, and neither is worth the failure mode today.

When the processor refuses. Exactly one refusal is retried untaxed: stripe_tax_inactive, "Stripe Tax hasn't been activated on your account". That is your own switch and nothing the shopper did.

taxes_calculation_failed is deliberately not on that list, and neither are customer_tax_location_invalid, invalid_tax_location, shipping_address_invalid or payment_intent_automatic_tax_incomplete. The processor returns the first of those for an address it cannot resolve as much as for a setup problem, so treating it as the account's fault would leave a shopper able to cause an untaxed sale with a bad postcode. Those stop the checkout with wording the buyer can act on — "We could not work out the tax for that address" — and nothing is sold.

The same rule holds for the tax calculation that bank transfer, PayPal and orders you create yourself use. It used to have a forgiving wrapper that answered zero tax on any refusal; that wrapper is gone, because it meant a bad postcode was a tax discount on every provider except the card one. The calculation now decides refusal by refusal: an address refusal becomes the same shopper-facing 400, and any other failure — an outage, a refused account — still takes the order rather than losing the sale but records the tax as fallback: "processor_refused" with the reason. Creating an order yourself is refused outright instead ("The tax on this order could not be worked out, so it has not been created"): you are sitting at the form and can fix it, and a silent zero on an invoice is found out at filing time.

A shopper never reads the processor's own message. A refusal that is nobody's fault answers one fixed sentence ("Checkout couldn't start right now"); the real message, its code and its parameter are logged. Processor messages are written for developers, name a vendor no shopper-facing string may name (§ "Grey label"), and have carried account ids and key fragments. Losing that sale helps nobody, and there is nothing the shopper or you can do about it in the next thirty seconds. But the sale is never allowed to look ordinary afterwards: the order records the tax fallback and the processor's own words, the order detail says so in place of the tax row, and you get one email a day telling you your store is selling without charging tax.

Two notices, one claim each. A store selling untaxed and a buyer whose country moved the tax are different problems with different fixes, so they are tracked separately — sharing one claim meant whichever happened first silenced the other for a day. Each claims its day with a single conditional write, not a read then a write: every untaxed checkout raises one and a shopper can retry as often as they like, so two arriving together used to read "nothing sent yet" and both send.

That notice keys off your store's own environment, not a deployment-wide live-mode flag: a live shop selling untaxed on a deployment still running on test keys used to tell nobody at all. A sandbox still never emails: that is you trying things.

Every other tax refusal stops the sale, loudly. A category the processor does not know, a behaviour it will not take, a tax ID it rejects — those are refusals of something Formahand sent, and dropping the tax would quietly sell your whole catalogue untaxed for as long as it took anyone to notice. One failed checkout is a far cheaper way to find out. The untaxed retry also refuses to fire when the processor's param names one of the tax parameters, whatever the message says.

A tax category is checked before it is stored, not at checkout. txcd_12345678 is exactly as well-formed as txcd_99999999 and is not a real code, so the shape of the string is not the check. The curated list is let through with no network call and anything else is looked up once against the processor's global tax-code catalogue, and the answer is cached for a short while. It guards every way in: PUT /api/tax-settings, set_tax_settings, POST/PUT /api/products and upsert_product. A processor Formahand cannot reach lets the save through rather than blocking you on an outage — the checkout refusal and the order's recorded fallback are the net under that.

What a paid order records

The order's taxMinor keeps the money (total_details.amount_tax, as it always did) and its tax breakdown keeps what it was made of: per jurisdiction the label, country, state, rate, inclusive flag, the amount and the taxable amount, the tax type (vat, sales_tax, gst, …) and the taxability reason (standard_rated, reverse_charge, customer_exempt, …), plus the business tax IDs the buyer gave.

Two processor facts shape that path. customer_details.tax_ids is on the checkout.session.completed event (Collect tax IDs), so the VAT number arrives with the webhook. total_details.breakdown is not: it is an includable field, returned only when the session is read back with expand[]=total_details.breakdown (the Session object). So it is fetched once, on a paid session that actually charged tax, and a failed fetch simply records no breakdown — the money is on the event either way.

Bank transfer, PayPal and orders you create yourself

Those never reach a Checkout Session, so the same money comes from the tax calculation, POST /v1/tax/calculations on the same connected account (Create a Calculation). It sends currency, one line_items[] entry per cart line with amount, reference, quantity, tax_code and tax_behavior, shipping_cost with the shipping category, and customer_details[address] + address_source: shipping.

The address is the catch. Bank transfer collects none of its own and PayPal only reveals one after approval, so the only jurisdiction either ever has is the "Ship to" estimate the shopper typed in the cart (already carried for shipping rates). Without one there is no tax: the order records 0 and says so, because inventing a jurisdiction is worse than charging nothing you then have to correct. PayPal's order carries the tax as its own amount.breakdown.tax_total line; bank transfer adds it to the order total on the instructions page.

An order you create yourself (create_order) is quoted on your store first — which is also how the line tax codes and your tax settings arrive — then calculated on the account, then created with the answer. Your own price on a line is what gets taxed, not the catalog price. No shipping country means no extra call and no tax.

The buyer does not get to pick their own jurisdiction

The cart's "Ship to" estimate is an unauthenticated form field. For a card session that is harmless — the processor taxes the address the buyer actually types on the payment page — but the two providers that price the cart themselves had to be closed:

  • PayPal takes the delivery address from the buyer's own account, which Formahand only sees after they approve, when the price is already fixed. So before the capture, the order is read back (GET /v2/checkout/orders/{id}), PayPal's country is compared with the one the tax was calculated for, and where they differ the sum is redone against the real address. If the tax moved, the capture does not happen.

Both ways of capturing go through that check. An approved PayPal order is captured either by the buyer returning to /api/payments/paypal/return or, for a buyer who never comes back, by the CHECKOUT.ORDER.APPROVED webhook — and the two race each other. Checking only one would be the same as checking neither: closing the tab would be the way round it. So there is one capture door both use and the re-check lives inside it. Whichever path loses the race gets ORDER_ALREADY_CAPTURED and raises no second event; both use the same paypal.capture:<id> event id, so a duplicate would be dropped anyway.

A refusal is not just a log line: the reservation is released immediately (a payment.expired keyed paypal.tax-mismatch:<id>, so PayPal's retries release once), you get a notice naming both countries, and the shopper goes back to the cart with checkout_error=tax-mismatch telling them their PayPal delivery country changed the tax and to confirm it. It stays quiet whenever it cannot be sure — no recorded basis for the original sum, no address on the order, a store it cannot reach, a calculation it cannot make — because refusing an approved payment over a failure of Formahand's costs you a real sale.

  • Bank transfer collects no address at all, so on a store that charges tax it refuses the checkout without a country rather than quietly charging zero. The cart already asks for one for shipping; this makes it required, and the storefront says exactly why. The country is still only the buyer's own word for it — nothing ever verifies it — so the order records the destination source as shopper_declared and the order detail says "Tax country entered by the buyer (XX) — nothing has verified it. Check it before you ship." A provider whose address is verified before the money moves records nothing, so the caution never cries wolf.

Every calculation records what it was made from — the destination and the basis, on the order's tax breakdown — which is what makes the PayPal re-check possible without guessing at the categories a second time.

Tax-exempt customer groups

A customer group marked tax-exempt finally means something, and the two paths use different mechanisms because the processor offers different levers:

  • The tax calculation — customer_details[taxability_override]=customer_exempt, the documented override for a customer whose exemption you have checked yourself. The order still records the jurisdictions it would have been taxed in, with taxability_reason: customer_exempt.
  • The Checkout Session — the session is built with no automatic_tax at all. The processor's tax_exempt lives on a saved Customer object and a Session only reports it back; creating and attaching a Customer per checkout to carry one flag is a whole object lifecycle Formahand does not have, and leaving the calculation off charges the exempt buyer exactly nothing, which is the point.

Either way the VAT box is not offered to an exempt buyer: a tax ID is only worth asking for when the tax it changes is being calculated. A buyer's VAT number is also the right way to get reverse charge for everyone else — the processor applies it from the number's format regardless of the government verification result — which is why tax_id_collection is on rather than a per-group setting.

Is the account able to do any of this?

GET /v1/tax/settings on your connected account (Tax Settings) answers status (active or pending), status_details.pending.missing_fields, defaults.tax_code and head_office.address. Formahand turns that into { status, missing, defaultTaxCode, country }, caches it for ten minutes and answers it on GET /api/payments/connect, so Payments can show it without a call of its own.

An account Formahand could not reach answers unknown, and unknown never switches tax off: refusing to charge tax because a status lookup failed would silently stop collection for a merchant who is registered and owes it.

The processor's own guidance is to confirm status is active before enabling automatic tax, because a half-set-up account returns errors or zero-tax calculations. Formahand does that in the interface, not at checkout time: the Payments section and the tax readiness item tell you what is missing and link straight to the embedded tax_settings and tax_registrations screens (§4c), where you switch automatic tax on and add a registration per region without leaving Formahand.

Where tax shows up

  • Storefront — "incl. tax" / "excl. tax" beside prices and a one-line note under the cart subtotal. Nothing at all when your store does not charge tax.
  • Checkout — a Tax line in the order summary: the amount once known, otherwise the sentence that says where it comes from.
  • Order confirmation email, order detail and packing slip — a Tax line, the jurisdictions and rates behind it, and the buyer's VAT number when they gave one.
  • Analytics — revenueMinor is net of tax (total minus tax), with taxCollectedMinor as its own figure, per period and per day. Tax is money you hold for a tax authority; counting it as revenue would overstate every store that charges it and make two stores in different countries incomparable. A refund goes back gross, so the tax share of it comes back out of taxCollectedMinor too (pro-rated by the refunded share of the order) — a fully refunded order collected no tax, and a 40% refund of a 29.75 order gives back 1.90 of its 4.75 tax — and refundedMinor is reported on the same net basis, so the two lines can be read against each other. Best sellers were always tax-exclusive and are unchanged.
  • Orders CSV — order_tax (already there), plus order_tax_rate, order_tax_jurisdictions and customer_tax_id.

Routes, tools and dashboard

GET/PUT /api/tax-settings (session, or settings:read / settings:write) is the one way in, and every field is validated there: enabled and pricesIncludeTax must be booleans; defaultTaxCode and shippingTaxCode must look like txcd_99999999 (40 characters at most) and name a code the processor's catalogue actually has, checked before the save (see above); an unknown field is rejected rather than ignored. A refusal is a 400 with the sentence that says what to fix — "A tax category looks like txcd_99999999. Shipping is txcd_92010001.", '"Prices include tax" is true or false.', or "Check the tax settings and try again." A PUT replaces the whole object, so read first. The agent tools are get_tax_settings and set_tax_settings, and upsert_product takes taxCode. Saving emits tax_settings.updated on your store's event log. In the dashboard the section lives in Settings → Payments, under "Payment details".

What is deliberately not here

No manual tax rates or tax overrides per region, no partial tax refund of your choosing (a refund returns the gross amount, tax included, and the recorded tax keeps what was charged — analytics pro-rates the tax out of it rather than rewriting the order), no OSS/IOSS returns, no filing, no per-line tax on order items, no tax on a subscription. Rates, thresholds and registrations are the processor's job and yours; Formahand's job is to ask the right question and keep the answer.

6d. An additional payment

Editing a paid order (orders §6) can raise its total, which leaves the buyer owing a difference. Formahand collects it the only honest way: by asking them.

Card orders. Formahand opens an ordinary Checkout Session on your connected account for one line worth exactly the difference, carrying metadata[order_id] and metadata[order_edit_id], and the buyer is emailed your store's order-payment-requested template with that link. It is not the order's own session, so nothing downstream would find the order by it: the connected-account webhook recognises order_edit_id before it looks the order up and settles the edit instead. Until it is paid or waived, the edit stays pending_payment, the order reports edits.balanceDueMinor, and no second edit may be opened.

The payment has to be this edit's. The session id and the amount paid travel with the edit id, and the balance is settled only when all three match what was asked for. A replayed event, a link paid after the balance was waived, and a session for the wrong amount therefore settle nothing — and because that is money your store has been sent by accident, it is never quietly kept: the order gets a note saying what arrived and that it was not applied, and it is logged.

The session carries no automatic tax: the edit was already priced with its tax by the tax calculation (§6c), so the difference is a flat amount and taxing it again would double-charge. On a tax-inclusive store the tax share of the difference is passed as taxMinor so Formahand's percentage still comes off the net, never off money held for a tax authority.

A saved card is never charged for it. A PaymentIntent on the card the buyer used for the original order would be taking money for a total they never agreed to. The hosted page is the consent.

PayPal has no equivalent here, so an edit that raises a PayPal order's total opens no page at all: the screen says so before you apply it ("This order cannot collect an extra payment here — ask the buyer yourself, then waive the balance"), the edit records the balance as owed with no link, and the order waits on it until you waive it. The other way out is to lower the order instead. Bank transfer / cash on delivery orders simply record the balance as owed, and you mark it settled when the money arrives.

A lower total refunds, through the same refund path — the same cap, the same per-processor dispatch and the same agent money limit every other refund obeys. The refund is issued before the order's total is lowered (a cap measured against a lowered total would refuse its own refund), and the apply then takes that money back off the recorded refunded amount, because the lower total has already accounted for it: the buyer's arithmetic stays "paid X, the order is now Y", never "paid X, the order is Y, and Y was also refunded".

8. Gift cards

A gift card is not a discount. A discount says what a thing costs; a gift card says who paid for it. That one sentence decides everything else on this page: a card carries a balance, what is left after a purchase is still the shopper's money, and it lives beside your orders rather than beside your discount codes.

They are on every plan, and Formahand takes nothing for them.

What it is

Sixteen characters, in four groups, drawn from an alphabet with no I, L, O, 0 or 1 — the five people get wrong when they type one off a screen or a card:

ABCD-EFGH-JKMN-PQRS

Your store never stores the code. It keeps a one-way value and the last four characters. A code exists exactly once — in the answer to the call that made the card, and in the email that carries it — and nothing afterwards can read it back: not this page, not the dashboard, not a tool, not you. That is what makes a leaked database worthless, and it is why a card that has gone astray is stopped and replaced rather than re-sent.

Buying with one

The shopper types it in the cart, beside the discount box. The store checks it on every render and again at checkout, so an edited cookie buys nothing.

What happens then, in order:

  1. Your store works out the bill: the goods, your rules and discounts, the shipping and the tax.
  2. The credit is taken off the card before anything is charged. It is a hold, not a spend.
  3. The payment page asks for what is left, showing the line Gift card ••••PQRS.
  4. When the order is paid, the hold becomes a spend, and the order records how it was paid.

The hold-before-charge order is deliberate and it is not close: hold first and the worst case is credit stuck on a checkout nobody finished, which is freed within the hour; charge first and the worst case is an order under-charged because the card turned out to be empty, which is money gone.

The cart shows the card's balance, not what it will pay. What it pays is not known until the shipping is picked and the tax is worked out, and a number that changes between the cart and the payment page is worse than no number at all; the payment page shows the reduction as a line, which is the first moment it is a fact.

Credit is only ever freed from a checkout that went nowhere. A hold whose order has left "pending" is a spend waiting to be recorded — the processor was asked for the reduced amount and only the record of it is missing — so it is left where it is, and the record still lands when it arrives.

Three rules and no others decide what a card pays: at most its balance, at most what is owed, and neither number ever goes below zero. A card worth more than the order keeps the difference; a card worth less pays what it can and the rest is charged.

What stops a card being used

What the shopper is told
Not a code your store knows"That gift card code isn't valid."
StoppedThe same sentence, on purpose — somebody trying codes must not learn they found a real one.
Past its expiry"That gift card has expired."
Nothing left on it"That gift card has no balance left."
A different currency to the order"That gift card is in a different currency to this order."

Where tax lands, honestly

Where Formahand works the tax out — bank transfer, PayPal, an order you made yourself — the card comes off after the tax, which is the right way round: the tax you owe does not change because somebody paid with credit.

Where the processor works the tax out on its own payment page, the reduction reaches it as a fixed amount off the session, and the processor then calculates tax on what is left. On a store that charges tax that means slightly less tax is collected than the goods alone would have attracted. It is the same behaviour an order you discounted already has. Today gift cards are offered on card checkout only, for this reason and because each payment method expresses a reduction differently; a cart carrying a card on bank transfer or PayPal simply pays in full, and the card keeps its whole balance because nothing was held.

Refunds

A refund goes back on the card first, capped at what the card actually paid on that order, and the rest goes back through the processor.

Card-first is the ordering that cannot go wrong: credit is the part of a payment you can return at no cost and with no dispute window, the cap is this order's own tender less whatever earlier refunds already returned, and so however many partial refunds an order collects, a card can never end up with more on it than came off it.

A refund onto a card you stopped, or one that has since lapsed, makes it spendable again and says so in the card's history. There is nowhere else for that money to go — a processor cannot be asked to return more than it took — and credit paid onto a card nobody can spend is a refund that refunded nothing. If you stopped the card for a reason, stop it again; that is a decision you can make, where money quietly disappearing is not.

The order

An order paid partly with a card records the whole bill as its total and the split beside it — what the card paid, and what the processor charged. The total is not the smaller number, because a card that paid half an order must not make the order look half the size: every refund cap, statement and report reads that field.

The split is written once, when the order is paid, and never recalculated. How an order was paid is a fact about a moment; a card whose balance moves later must not rewrite it.

Making one yourself

Orders → Gift cards is the till: every card, what it is worth now, what it was worth when it was made, who made it, and its whole history — issued, held at a checkout, spent, given back when a checkout was abandoned, put back by a refund, changed by hand, stopped.

Issue a gift card puts credit in it. The code appears once, on that screen.

Selling them

Install the Gift cards add-on (Online Store → Apps & functions → Add-ons). It turns a paid order line into a card and asks your store to email the code to whoever the buyer named. See apps.md and the add-on's own README.

Mark the product you are selling as Kind: Gift card (product-kinds.md) so checkout asks for no address and the thing never runs out of stock.

Through the API

GET /api/gift-cards reads the till (orders:read); ?id= is one card with its ledger and ?code= is one balance and nothing else — no id, no history, and the same sentence for an unknown code as for a stopped one.

POST /api/gift-cards issues one and PATCH stops or adjusts one. Both need **gift_cards:issue**, a scope nothing else implies — not orders:write, not settings:write — because issuing credit is issuing money.

A token issuing credit is held to the same two money limits you already set for refunds (Settings → Agents & API), counted separately from refunds so an agent making gift cards all morning cannot leave a real refund refused in the afternoon. Over a limit it gets a sentence and a link to the dashboard, not a card. A signed-in owner or admin has no limit, exactly as they have none on a refund.

The tools are list_gift_cards, get_gift_card, issue_gift_card, void_gift_card and adjust_gift_card (agents.md).

Events

gift_card.issued when credit goes in the till, gift_card.redeemed when an order spends some, gift_card.voided when one is stopped, gift_card.updated when a balance is moved by hand. The code is never in the payload (events.md).

The email

Gift card in Automations → Email templates carries the code to whoever it is for, in your voice from your own address. It is the only email that ever contains a code. Edit it like any other.

Not yet

  • Bank transfer and PayPal checkouts (the card is simply not used, and keeps its balance).
  • A card that is good on more than one of your stores.

Buying a gift card with a gift card

You cannot, and the cart does not pretend otherwise: the gift cards in a basket are left out of the bill a card is measured against, so credit pays for everything else and the cards themselves are paid for by the processor. Everything else about the checkout is unchanged.

It is not a restriction for its own sake. A balance poured from one code into a fresh one escapes the first card's expiry and a card you stopped, and it puts a card in the till that no money ever paid for — so the till stops describing what you are owed.