Ulabase

Build

Stripe billing

Take payments from your app without a payment backend: subscriptions or a product catalog with checkout, billed through your own Stripe account, in four steps.

Take payments from your service with no payment backend to write.

The Stripe page

You can sell:

  • Products: a catalog, a cart, card payment, and an order you can look up afterwards.

  • Subscriptions: recurring plans, seats, and the Stripe billing portal.

Either one, or both. They share the same keys and the same webhook.

Tip
The ecommerce starter is a working shop to copy, set up by a single ulabase setup command.

Before you start

You need a Stripe account. It is free, and it is yours: Ulabase bills you for the service, and your Stripe account bills your customers. The two never mix.

Stay in test mode until the whole flow works. The toggle is at the top right of the Stripe dashboard, and the test card is 4242 4242 4242 4242 with any future expiry and any CVC.

1. Get your secret key

Developers → API keys. Copy the one starting sk_test_.

2. Create the webhook

The webhook is how Stripe tells your service that a payment went through. Without it, people pay and their orders stay pending.

Developers → Webhooks → add an event destination.

Destination URL: your service, plus /stripe/webhook:

https://c0ffee.ulabase.app/stripe/webhook
Important
Use your service’s own URL, from its Home page. api.ulabase.com is Ulabase’s control panel and serves none of your collections.

Events: select these six:

checkout.session.completed
checkout.session.async_payment_succeeded
checkout.session.async_payment_failed
checkout.session.expired
charge.refunded
charge.dispute.created

The first marks an order paid. The rest cover slow payments, abandoned carts, refunds and disputes.

Save it, then reveal the signing secret. It starts whsec_.

Note
Your service is on the public internet, so Stripe reaches it directly. You do not need the Stripe CLI or stripe listen, which are for a webhook arriving at your laptop.

3. Set it up

With the two values in hand:

npm install -g ulabase

export STRIPE_SECRET_KEY=sk_test_...
export STRIPE_WEBHOOK_SECRET=whsec_...

ulabase login
ulabase setup --srv <srvId>

ulabase login asks for a personal access token: create one at https://ulabase.com/me/tokens. Your account password does not work here.

Or do it from the console: your service → Stripe → Enable, then fill in the two secrets.

4. Check it worked

Buy something with the test card, then look at your orders collection. The order should read status: "paid".

If it still reads pending_payment, the webhook is not arriving. In Stripe, open your event destination and look at the recent deliveries:

  • 404 or 401: the URL is wrong; it must end in /stripe/webhook.

  • 400: the signing secret does not match the one you configured.

  • nothing at all: checkout.session.completed is not in your selected events.

Keeping the setup in git

ulabase setup runs a file you commit with your app, so setting up a new service takes one command. This is the file:

import { defineSetup, step, fromEnv, isRedacted } from '@ulabase/cli';

/** A stored secret reads back as bullets; one never set reads back blank. */
const configured = (v: unknown) =>
  isRedacted(v) || (typeof v === 'string' && v.length > 0);

export default defineSetup('Shop', [
  step('stripe feature installed', {
    check: ({ admin, srvId }) => admin.isFeatureInstalled(srvId, 'stripe'),
    apply: ({ admin, srvId }) => admin.installFeature(srvId, 'stripe'),
  }),

  step('stripe configured', {
    async check({ admin, srvId }) {
      const c = await admin.getFeatureConfig(srvId, 'stripe');
      return configured(c['secret-key']) && configured(c['webhook-secret']);
    },
    async apply({ admin, srvId }) {
      const current = await admin.getFeatureConfig(srvId, 'stripe');
      await admin.updateFeatureConfig(srvId, 'stripe', {
        ...current,
        'secret-key': configured(current['secret-key'])
          ? current['secret-key']
          : fromEnv('STRIPE_SECRET_KEY'),
        'webhook-secret': configured(current['webhook-secret'])
          ? current['webhook-secret']
          : fromEnv('STRIPE_WEBHOOK_SECRET'),
      });
    },
  }),

  step('collections and indexes initialised', {
    check: ({ service }) => service.collectionExists('transactions'),
    apply: ({ admin, srvId }) => admin.initFeature(srvId, 'stripe', 'products'),
  }),
]);

Run it as often as you like. Every step checks before it writes, so a second run against a configured service changes nothing. --dry-run tells you what is missing and changes nothing.

Secrets are needed only the first time. A stored key reads back as bullets, and configured() passes them through, so a re-run needs nothing in your environment.

Guests must be allowed to shop

Stripe moves the money, and your service’s ACL decides who may reach the collections. A shop that sells to people without accounts needs three permissions. When one is missing, the symptom does not look like a permissions problem:

Permission Symptom when missing

GET /catalog anonymously

The shop is empty. No error.

POST /orders anonymously

Guest checkout answers 401 at the last click.

GET /orders/{id} anonymously, filtered on ?secret=

The buyer pays, then the return page, the one that confirms the order, answers 401.

Tip
Scope the catalog rule with a readFilter. Once the rule exists, every catalog document it matches is public, draft products included. Filtering on { purchasable: true } alone is not enough once you sell variants: see below.
mongo: {
  readFilter: {
    $or: [
      { purchasable: true },
      { variants: { $elemMatch: { purchasable: { $ne: false } } } }
    ]
  }
}

The ecommerce starter has all three written out, with the success URL that carries the order reference.

Variants, and what is on the shelf

A product with options, such as colours and sizes, carries them inside its own document, as variants. You need no second collection and no product per combination:

{
  "_id": "tee-classic",
  "name": "Classic T-shirt",
  "description": "Heavyweight cotton",
  "images": ["https://…/tee.jpg"],
  "currency": "eur",
  "variants": [
    { "id": "yellow-l",
      "unit_amount": 2500,
      "purchasable": true,
      "in_stock": 12,
      "images": ["https://…/tee-yellow.jpg"],
      "metadata": { "colour": "yellow", "size": "L" } }
  ]
}

The whole model is five rules:

  1. A document without variants is bought as itself. Most products have no variants and need no list.

  2. A document with variants is not buyable; its variants are. The document carries what they share: name, description, images.

  3. A variant is referenced as <product-id>/<variant-id>: tee-classic/yellow-l. That is a productId like any other in POST /orders, so a client never needs to know there are two shapes.

  4. Variant ids need to be unique inside their document only.

  5. A variant inherits every field it does not declare. Price and stock sit in fields beside the metadata, never inside it.

metadata holds labels that describe the variant. Whatever keys you choose reach the order line, the Stripe dashboard, the receipt and the invoice, and your email templates can read them by name: {{colour}}, if that is what you called it. Stripe’s limits apply: at most 50 keys, keys under 40 characters, values under 500.

in_stock is optional, and nothing is reserved

in_stock goes on the variant, or on the product when it has no variants. Leave it out and the item is not counted, which suits most of what a small shop sells.

A cart reserves nothing. The units come off when the payment lands, in one atomic update per line, so two people can buy the last one and both payments succeed. The order that pushed the count below zero is marked oversold: true. You refund it from the Stripe dashboard; the refund comes back as charge.refunded, which is already handled.

This is a deliberate choice. Reserving would need an endpoint, a server-issued token, a per-cart cap, a rate limit, expiry rules and a server-side cart, all to handle two people buying the last unit in the same half hour. An oversold order costs a fee and an apology, and it is rare. The atomic decrement makes the case visible, so you can refund it.

Checkout refuses what already reads zero, so the risk is limited to the time between that check and the payment.

Note
Nothing puts stock back. After a refund the goods may come back, come back broken or never come back, so restocking is a person’s job, from the console.

Common mistakes

Filtering the catalog on purchasable alone. A product with variants has no top-level purchasable: the flag is on each variant. A missing field does not match, so { purchasable: true } hides every product with options, and the shop renders without them. No error appears.

Pointing the app at the admin node. api.ulabase.com is Ulabase’s control panel and serves none of your collections, so every GET /catalog answers 401, which looks like a missing permission. Your service’s URL is the one on its Home page.

A success-url that does not match a route in the app. The buyer pays and lands on a 404. Stripe substitutes {CHECKOUT_SESSION_ID}, and RESTHeart also fills in {ORDER_ID} and {ORDER_SECRET}. Put those in the URL fragment, so the secret never reaches a server log or a Referer header.

Renamed collections the client does not know about. products.catalog-collection and products.orders-collection are configurable, and the client takes them as parameters. If you rename one, pass the new name to the client.

Order emails

The Stripe feature sends order confirmations and refund notices. They come from noreply@ulabase.com unless you set up your own mail server: see Email, which also covers the daily limit they count against.