Payments and the shop

Embed
Preview of the card
<a href="https://forumulate.com/guides/payments-and-shop"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/e5a37c79-2336-4673-a3cd-ad2b304bfdb8/social-card.svg?v=3e7ae33eec8b" alt="Payments and the shop" width="600" height="315"></a>

Everything on Forumulate that takes money takes it through your own Stripe account. There is no Forumulate payment account in the middle and never will be: a buyer pays you directly, Stripe pays out to you, and we only record what was bought so the right reader gets the right seat, post or order. We take no cut.

One connection serves every paid feature in the organization:

Feature

What is charged

Guide

Blog memberships and paid posts

a recurring subscription (or a gift)

Blog memberships

Event tickets

a ticket per seat

Events spaces

Courses

a one-off course purchase

Courses spaces

Paid forms

a fixed or chosen amount with a form response

Forms

Community paid membership

a recurring membership or a one-off pass

Community spaces

The shop

products from your catalog, through a cart or "Buy now"

below

Card details are only ever typed into Stripe Checkout, the payment page Stripe hosts. No card number reaches a page we serve, so your pages stay out of PCI scope.

Which plans can take payments

Forms are on every plan, the free plan included: every question type, branching, scoring and integrations. Taking payments is on paid plans only (Starter and up): a paid form, and the shop — its products, cart and checkout.

The reason is fraud, not money. Free accounts that accept payments are a common abuse vector (stolen cards tested against a throwaway storefront), and since payments go through your own Stripe account and we take no cut, the plan is the brake we can put on it.

What this means in practice:

  • On the free plan the studio shows an upgrade prompt where a form's payment switch and the shop's Add product button would be.

  • The API refuses a new payment with 402 and the code plan.required, naming the plan that includes it in details.requiredPlan (starter) and the feature in details.feature (commerce.payments). It is refused where a payment is enabled or started: creating a form with a payment, adding a payment to a form, creating a product, and starting a checkout (a cart, or a paid form's submit). A buyer who meets the refusal is told the site cannot take payments right now and that nothing was charged.

  • Switching a form's payment off is always allowed, on any plan.

What already existed keeps working. Every paid form and every product that existed on an organization without a paid plan when this rule arrived (20 October 2026) is grandfathered: it keeps taking payments on any plan, and you can still edit and re-price it. Only new paid forms and new products need a paid plan. An organization that leaves a paid plan keeps its grandfathered items, but anything it set up while on the paid plan stops taking payments until it upgrades again.

Until these are done, nothing paid can be bought: the buy buttons are disabled or say so, and the API answers 409 with "This organization does not take card payments yet — connect Stripe in Settings → Payments."

  1. A Stripe account of your own. Test mode works for everything below.

  2. A secret key. In Stripe, under Developers → API keys, copy a secret key (sk_test_… / sk_live_…). A restricted key (rk_…) is enough and is the safer choice. It needs write access to Checkout Sessions and Refunds. Blog memberships and community paid memberships also read and cancel Subscriptions, so give the key that access if you sell them.

  3. Enter it in the console: Settings → Payments, then Save. Press Test to have Stripe confirm the key works. The panel then shows whether the key is live or in test mode.

  4. Turn sales on. The switch in the same panel (labelled Sell paid tickets) is the enabled flag, and it covers every paid feature, not only tickets. Saving a first key turns it on. With it off, nothing new can be bought, but refunds and the webhook keep working.

  5. The webhook. The Payments panel shows your organization's webhook address, https://forumulate.com/api/public/payments/stripe/{orgId}/webhook. In Stripe, under Developers → Webhooks, add an endpoint at that address and select these events:

    Event

    Used by

    checkout.session.completed

    everything

    checkout.session.expired

    tickets, shop, paid forms (the order is marked abandoned)

    charge.refunded

    everything (a refund made in the Stripe dashboard is mirrored)

    customer.subscription.updated, customer.subscription.deleted

    blog and community memberships (renewals, cancellations)

    invoice.paid, invoice.payment_failed, checkout.session.async_payment_succeeded

    community paid membership

    Copy the endpoint's signing secret (whsec_…) and paste it into the Payments panel as the Webhook signing secret.

  6. Stripe Tax (optional, shop and paid forms only): if you want tax added at checkout, activate Stripe Tax in your Stripe dashboard first, then turn on Add tax with Stripe Tax in Settings → Shop. If you turn it on without setting Stripe Tax up, Stripe refuses to start the checkout.

One webhook for everything. Your organization has one Stripe connection, so it has one webhook. Each checkout we create carries a metadata.kind (event_ticket, course, shop, form_payment, blog_membership…), and the webhook hands every event to the feature that owns it. You never add a second endpoint for a second feature.

Without a signing secret, webhook events are ignored. An event we cannot verify is acknowledged and dropped, in every environment, because the webhook books paid seats. Purchases still complete without it: when Stripe sends the buyer back, the return page asks Stripe directly, with your key, whether the session was paid, and settles it. What you lose without the webhook is everything that happens when the buyer does not come back: a closed tab after paying, abandoned checkouts, refunds made in Stripe, subscription renewals and cancellations. Set the secret.

The keys are write-only

The secret key and the signing secret are encrypted at rest and never shown again, to anyone, including you. GET /orgs/{orgId}/payments/stripe says whether each is set (secretSet, webhookSecretSet), the mode (live or test), when the key was last verified and the last error. To change a key, send a new one. Leaving a field out of a PUT keeps what is stored, and webhookSecret: null removes the signing secret.

curl -X PUT "https://forumulate.com/api/orgs/$ORG/payments/stripe" \
  -H "Authorization: Bearer $FORUMULATE_TOKEN" -H "Content-Type: application/json" \
  -d '{ "secretKey": "rk_test_…", "webhookSecret": "whsec_…", "enabled": true }'

curl -X POST "https://forumulate.com/api/orgs/$ORG/payments/stripe/test" \
  -H "Authorization: Bearer $FORUMULATE_TOKEN"
# { "ok": true, "accountName": "Stripe (test mode)" }

DELETE /orgs/{orgId}/payments/stripe disconnects Stripe. All four routes need org.manage_settings. Connecting, changing and disconnecting fire the payments.connected, payments.updated and payments.disconnected webhooks. They carry flags only (enabled, mode, keyChanged), never a key.

The shop

The shop sells products from your organization's catalog on any page, the way Webflow's ecommerce does: a product block shows cards with a price, readers add to a cart or buy one straight away, and pay on Stripe Checkout.

Products

Products live in Settings → Shop → Products (or POST /orgs/{orgId}/shop/products).

Field

Default

What it does

name

(required)

Up to 120 characters.

slug

from the name

Lower-case letters, digits and hyphens. Unique in the organization (a clash answers 409).

description

none

Up to 2,000 characters, shown on the card.

imageUrl

none

An uploaded picture (/api/assets/…) or an https:// address. Readers load it through our image proxy, so the picture's host never sees them.

priceCents

(required)

The price in the smallest unit (1999 is 19.99).

currency

usd

Three letters, lower case.

stock

null

Units left. null is not counted (a download, a service).

physical

false

Ask for a shipping address at checkout.

active

true

On sale. An inactive product is hidden from every page.

position

0

Its order in the catalog.

spaceId

null

The space that sells it. null puts it in the whole organization's catalog.

A reader never sees a stock count, only "Sold out" when stock reaches zero.

The product block

In the editor, type / and choose Products (searching "shop", "buy" or "cart" finds it too). The block names products by id, in the order you pick them, or names none, which means "the catalog's active products, in catalog order".

Attribute

Default

What it does

productIds

[]

Up to 50 products. Empty = the catalog.

layout

grid

grid of cards, a list of rows, or one feature (picture beside the words).

columns

3

Cards per row in a grid, 1–4.

mode

cart

cart adds to a cart; buy goes straight to checkout for one unit.

showImage

true

showDescription

true

buttonLabel

"Add to cart" / "Buy now"

Up to 40 characters. Without it the words follow the page's language.

limit

50

How many, when the block shows the catalog.

The block carries no copy of the products. Prices, names and pictures are read from the catalog every time the page is rendered, so a price you change in Settings → Shop is the price on every page at once, and a page can never state a price of its own. In markdown the block is :::product{data="…"}::: (Markdown directives).

The cart is kept in the reader's browser, per organization, and shown as a floating button with a drawer. It is a convenience, not a record: when the reader checks out, the API prices every line again from the catalog and ignores whatever the browser thinks a product costs. One cart pays in one currency, so mixing currencies is refused with a 422.

Checkout

Checkout opens a Stripe Checkout page on your account. What it offers is set in Settings → Shop → Checkout:

Setting

Default

What it does

shop.promotion_codes

off

Accept promotion codes. Buyers can enter a code on the Stripe page. You create the codes in your own Stripe dashboard (Products → Coupons).

shop.automatic_tax

off

Add tax with Stripe Tax. Stripe works out sales tax or VAT. Needs Stripe Tax set up in Stripe first.

shop.ship_to

empty

Ships to: two-letter country codes, comma separated (US, CA, GB). Only asked when the order holds a physical product. Empty means a built-in list of 46 commonly shipped-to countries (the US, Canada, the UK and Ireland, the EU and EEA, Switzerland, Australia, New Zealand, Japan, Singapore, Hong Kong, Mexico, Brazil, India, South Africa, the UAE, Israel, South Korea and Taiwan). Stripe needs an explicit list, so name your countries if they differ.

These are ordinary organization settings, so they can also be written with PUT /orgs/{orgId}/settings/{key}. They apply to paid forms too.

The return address rule. Stripe sends the buyer back to the page the cart sits on, and the API checks that page first. It must be https:// and must be one of your addresses: our own host, or a custom domain your organization has verified and activated. Anything else is refused with a 422 (returnUrl: ["foreign"]). Without this, a crafted request could turn your checkout into a redirect to anywhere. The reader script, for its part, only ever navigates to Stripe's own checkout host.

What happens after paying. The order is created as pending when checkout starts. It becomes paid exactly once, whichever arrives first, the webhook or the buyer's return to your page (POST /public/orgs/{orgId}/shop/confirm/{sessionId} asks Stripe and settles the order). That single transition takes the stock, fires shop.order.paid and clears the reader's cart. Stock is not reserved while the buyer is on Stripe. It is checked when checkout starts and taken when the money arrives. If the last unit sold in between, the order is refunded automatically: overselling is worse than a rare refund.

Orders and refunds

Settings → Shop → Orders lists every order with its items, total, buyer and status: Awaiting payment (pending), Paid, Refunded, or Abandoned (expired, the checkout timed out). A paid order has a Refund button, which refunds it in full through Stripe and puts tracked stock back. A refund you make in the Stripe dashboard is mirrored here through the webhook.

curl "https://forumulate.com/api/orgs/$ORG/shop/orders?status=paid" \
  -H "Authorization: Bearer $FORUMULATE_TOKEN"

curl -X POST "https://forumulate.com/api/orgs/$ORG/shop/orders/$ORDER/refund" \
  -H "Authorization: Bearer $FORUMULATE_TOKEN"

Orders from paid forms appear in the same list with kind: "form", linked to the form and the response (formId, submissionId).

Webhooks (Webhooks): shop.order.paid (order id, kind, items, total, currency, buyer email, shipping address) and shop.order.refunded. A paid form also fires form.paid.

Who can do it. The catalog, the checkout switches, the orders and refunds all need org.manage_settings, the same capability as the Stripe connection: the money and what is sold against it are one permission.

The public side

GET  /public/orgs/{orgId}/shop/products?ids=…&spaceId=…   the catalog a block draws (active products only)
POST /public/orgs/{orgId}/shop/checkout                   { items: [{ productId, quantity }], returnUrl, email? } → { url, orderId }
POST /public/orgs/{orgId}/shop/confirm/{sessionId}        the return page's check → { status, orderId }

A checkout takes 1–50 lines, each 1–99 units. The routes are throttled per network (checkout at 10 a minute). Nothing on them accepts a price.

A form can take a payment with each response, Typeform- and Tally-style. Turn on Take a payment with each response in the form's editor (forms.payment): a fixed amount, or the amount from one of the form's own answers, bounded by a minimum and maximum. The answers are filed first as pending, the reader pays on Stripe Checkout, and only then is the response announced to you. Details in Forms.

Taking a payment needs a paid plan (above); the form itself does not.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Org Payments

Operation

Request

What it does

Shortcuts

OrgPayments_status

GET /orgs/{orgId}/payments/stripe

Get the stripe in an organization

client.orgs.payments.status()

OrgPayments_set

PUT /orgs/{orgId}/payments/stripe

Replace the stripe in an organization

client.orgs.payments.set()

OrgPayments_clear

DELETE /orgs/{orgId}/payments/stripe

Delete the stripe in an organization

client.orgs.payments.clear()

OrgPayments_test

POST /orgs/{orgId}/payments/stripe/test

Test the stripe

client.orgs.payments.test()

Public Shop

Operation

Request

What it does

Shortcuts

PublicShop_list

GET /public/orgs/{orgId}/shop/products

List products in an organization (public reader)

client.public.shop.list()

PublicShop_start

POST /public/orgs/{orgId}/shop/checkout

Create the checkout in an organization (public reader shop)

client.public.shop.start()

PublicShop_confirm

POST /public/orgs/{orgId}/shop/confirm/{sessionId}

Create the confirm

client.public.shop.confirm()

Shop

Operation

Request

What it does

Shortcuts

Shop_list

GET /orgs/{orgId}/shop/products

List products in an organization

client.shop.list()

Shop_create

POST /orgs/{orgId}/shop/products

Create a product in an organization

client.shop.create()

Shop_update

PATCH /orgs/{orgId}/shop/products/{productId}

Update the product

client.shop.update()

Shop_remove

DELETE /orgs/{orgId}/shop/products/{productId}

Delete the product

client.shop.remove()

Shop_orders

GET /orgs/{orgId}/shop/orders

List orders in an organization

client.shop.orders()

Shop_refund

POST /orgs/{orgId}/shop/orders/{orderId}/refund

Create the refund in an organization

client.shop.refund()

The Forumulate teamUpdated