
# Commerce — reference

> Load this file when: evaluating, integrating with, or troubleshooting the **Commerce app** — its three parts (CPQ, eCommerce, the payment platform), the consumer contract another app or feature uses to take money, and the seams to Events and Keyring. Building *inside* Commerce is project documentation, not this page.

> 🔒 **Works by Design system — not a HubSpot platform feature.** Commerce is our private HubSpot app; the `/hs/serverless/…` endpoints below exist only on portals where we have installed it. The **contract** is the portable part — server-derived amounts, verify by bucket rather than raw provider status, the callback as a poke rather than a source of truth, money separated from fulfilment — and it is worth copying against whatever payment layer you do have. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md)

## What the app is

Commerce is one HubSpot app doing three jobs that share a product catalogue, a pricing engine and a payment layer: it quotes to businesses, sells to consumers, and takes the money for both — and for anything else on the portal that needs to charge for something.

| | |
|---|---|
| **Kind** | Private HubSpot app (platform 2026.03), installed per portal |
| **The three parts** | **CPQ** — configure/price/quote for B2B · **eCommerce** — catalogue, cart and checkout for B2C · **Payment platform** — the provider seam both use, and the one other apps buy through |
| **Stores data in** | HubSpot's own commerce objects wherever they exist — Products, Line Items, Quotes, Orders, Carts, Invoices, Payments — plus a small number of custom objects for what HubSpot has no native home for |
| **Surface count** | 59 HTTP endpoints declared in the project at the `verified` date above |
| **How to get it** | Not distributed. Ask for a walkthrough: [w@reus.ie](mailto:w@reus.ie) |

> [!NOTE]
> This page is Commerce's **permanent entry point** — the URL will not change. It is deliberately an overview plus the one contract outsiders actually need; the internals (provider modules, the payments mirror, the subscription lifecycle) are project documentation and are not published here.

## The payment contract

This is the part of Commerce another app touches, and the reason it exists as a platform rather than a checkout page. The division of labour is the whole design:

> **Money is Commerce's job. Fulfilment is yours.**

You create an Order, hand the buyer to Commerce's checkout, and get told when the money moved. What that means — issue a ticket, mint a voucher, unlock a download, start a subscription — is yours alone. Commerce never touches your records, and you never talk to a payment provider, never hold a key, and never interpret a provider's status vocabulary.

Six steps. Every consumer is the same shape; only the last one differs.

| | Step | What you do |
|---|---|---|
| 1 | **Create an Order** | `POST /hs/serverless/create-order` with the buyer's `contactId`, your own app name as `externalSource`, and your own correlation id as `externalRef`. Add line items where you want a real itemised order. **Server-side only** — the order total is what gets charged |
| 2 | **Create the payment** | `POST /hs/serverless/create-payment` with the `orderId`, a `redirectUrl` for the buyer and, optionally, a `callbackUrl` for your backend. You get back a hosted checkout URL |
| 3 | **Send the buyer** | Redirect to that URL. The provider, the card form and the compliance surface are Commerce's problem from here |
| 4 | **Verify** | When the buyer returns, ask Commerce whether the money moved. Verify by **outcome bucket**, never by a raw provider status string — provider vocabularies differ and change |
| 5 | **React to the callback** | The signed completion callback is a *poke*, not a source of truth: it tells you to go and check, and step 4 is what you believe. Treat it as optional — it can be missed, duplicated or late |
| 6 | **Fulfil** | Your side. Idempotent, keyed on your own `externalRef`, and safe to run twice — because steps 4 and 5 can both fire |

Full request and response shapes, the bucket table, and the reconciliation job are in `backend/payments-consuming.md` in our documentation corpus — reachable over the [Docs MCP](/docs/mcp), or ask and we will send it. The complete endpoint inventory lives alongside it in [commerce-endpoints.md](commerce-endpoints.md), generated from the app's own manifests; it is a working document for people building *on* Commerce rather than buying through it, which is why it stays in the corpus rather than appearing here.

### Providers

The provider seam is a module contract, so adding a payment service provider is a bounded piece of work rather than a rewrite. Status is not uniform, and the difference matters:

| Provider | Status |
|---|---|
| **Mollie** | Live-verified — the provider the platform was proven against end to end |
| **Stripe** | Implemented, **not yet live-verified**. The webhook endpoint is not registered automatically; it must be added in the Stripe dashboard |
| **Buckaroo** | Implemented, **not yet live-verified**. Two keys per mode, and test and live are different hosts |
| **Simulator** (`mock`) | Provider-independent testing, and the only way to produce a chargeback on demand |

"Implemented, not yet live-verified" means the code is written and unit-tested and the runbook exists, but no real money has moved through it. Run the runbook before pointing anything real at one.

## Surfaces

| Surface | Where | What it is |
|---|---|---|
| **CPQ quote modules** | Quote templates | The B2B quote a customer receives — configurable modules, not a fixed template. Documented separately: [CPQ quote modules](../guides/cpq-quote-modules-zero-to-hero.md) |
| **eCommerce module** | Website pages | Catalogue, cart and checkout for B2C |
| **Commerce app page** | HubSpot, Marketplace menu → Commerce | The operator cockpit — revenue KPIs and trends, the work queue, and the payment-platform status tile |
| **Settings** | HubSpot app settings | Provider credentials and mode, the voucher provider, and the per-portal configuration below |

## Integration seams

Commerce is one of three of our apps that share a portal. Both seams below run through the payment contract or a public endpoint — no shared code, no privileged lane.

| With | What crosses |
|---|---|
| **[Events](events-app-api.md)** | Paid tickets. Events is a payment consumer exactly as described above: it creates the Order with `externalSource: "events"`, and issues tickets on its own side once the money is confirmed. Commerce never knows what a ticket is |
| **[Keyring](keyring-reference.md)** | Vouchers at checkout. A shopper types a Keyring gift-card or voucher code into the cart; Commerce validates it, recomputes the discount **from the live cart** server-side, and spends it against Keyring only when the order genuinely reaches paid. Commerce integrates with Keyring exactly as an external point-of-sale system would — see [keyring-integration.md](keyring-integration.md) |

Neither is required, and both ship switched off.

## Conventions and gotchas

- **Verify by bucket, never by raw status.** Provider status vocabularies are not portable and do change under you. This is the single rule most likely to be broken by a consumer reading a provider's own documentation instead of this contract.
- **One payment settles exactly one invoice.** A HubSpot invoice shows as paid because an associated Payment record rolls up into it, and that association is one-to-one. An order that is expected to produce both a receipt invoice and a subscription invoice will not get both from a single payment — which is why a paid order carrying a recurring line item deliberately gets no receipt invoice.
- **A recurring line item leaves a native HubSpot Subscription behind.** HubSpot then generates each cycle's invoice and charges nobody — collection for later cycles is not automated. Verified on a live portal; treat any claim that it "just bills" as wrong.
- **The search index lags writes.** A record created a moment ago may not be findable by search yet. Read back by id, or drive lists by `hs_createdate` cursor rather than search, whenever correctness depends on seeing what you just wrote.
- **Every serverless response body must be `JSON.stringify(...)`.** Returning a plain object produces an empty HTTP 200 with no error anywhere — a platform behaviour, not a Commerce one, and it has cost real time on every app we have built.
- **Per-portal configuration is genuinely per-portal.** Provider credentials, the portal's own public domain, and any host the app is allowed to call are set per install. A setting that *looks* complete can still be dead if it points at a record that has since been archived — which is why the settings surface has connection tests that mint a real session rather than checking that fields are non-empty.

## Verifying it works

A green settings page is not evidence. In order, on the portal you care about:

1. Open the **Commerce app page** (Marketplace menu → Commerce) and confirm the payment-platform tile reports a configured provider and mode.
2. Run a payment through the **simulator** (`mock` provider) end to end: create an order, complete the checkout, and confirm your own fulfilment ran exactly once.
3. Confirm the order shows as paid **in HubSpot** — not only in the app — by finding the Payment record associated with the invoice.
4. Produce a **chargeback** in the simulator and confirm a dispute case appears. This is the path that is never exercised by a happy-path test and is the one most likely to be broken.
5. Only then switch the provider to a real one, in test mode, and repeat steps 2–4 with the provider's own test credentials.
