Downcity
Downcity City DocsGuides

Stripe Payment Service

Use @downcity/services to turn one-time Stripe payments into global balance topups.

The Stripe payment service currently does one thing:

after a user completes a one-time Stripe payment, sync that result into a balance topup.

It does not handle subscriptions or studio access grants.

Enable

import { balanceService } from "@downcity/services";
import {
  paymentService,
  stripePaymentMethod,
  stripePaymentService,
  type StripePaymentServiceBalanceBridge,
} from "@downcity/services";

const balance = balanceService({
  unit: "credits",
});
const stripeBalanceBridge: StripePaymentServiceBalanceBridge = {
  readTopup: async (topup_id) => await balance.readTopup(topup_id),
  finishTopup: async (topup_id, extra) => await balance.finishTopup(topup_id, extra),
};

base.use(balance);
base.use(paymentService({
  methods: [
    stripePaymentMethod(),
  ],
}));
base.use(stripePaymentService({
  balance: stripeBalanceBridge,
  secret_key: process.env.STRIPE_SECRET_KEY,
  webhook_secret: process.env.STRIPE_WEBHOOK_SECRET,
}));

Common flow

1. Read payment methods first

const methods = await guest.service("payment").get("methods");

The frontend can read methods.items first, then decide whether to show a Stripe recharge entry on the current City.

2. Create a topup first

const topup = await user.service("balance").action("topups/create").invoke({
  amount: 500,
  note: "recharge",
});

3. Create a Stripe Checkout Session

const checkout = await user.service("payment.stripe").action("checkout/create").invoke({
  topup_id: topup.topup_id,
});

The response includes:

  • payment_id
  • stripe_checkout_session_id
  • checkout_url

The frontend can redirect to checkout_url.

4. Stripe calls the webhook

Stripe should call:

POST /v1/payment.stripe/webhook

So when you register the endpoint in Stripe Dashboard, the default value is:

{base_url}/v1/payment.stripe/webhook

When webhook_secret or STRIPE_WEBHOOK_SECRET is configured, the service verifies stripe-signature.

When it receives checkout.session.completed, the service calls balance.finishTopup() and credits the wallet.

If you deploy on Workers, write both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET into City env after deployment.

If STRIPE_SUCCESS_URL / STRIPE_CANCEL_URL are not configured explicitly, the service derives default redirect pages in this order:

  1. DOWNCITY_CITY_BASE_URL
  2. the current request origin

The derived paths are:

  • /v1/payment.stripe/redirect/success
  • /v1/payment.stripe/redirect/cancel

That means many setups no longer need STRIPE_SUCCESS_URL / STRIPE_CANCEL_URL, and often do not need to configure DOWNCITY_CITY_BASE_URL either.

Only override them when you want Stripe to return users to your own frontend pages:

  • STRIPE_SUCCESS_URL
  • STRIPE_CANCEL_URL

Relationship with balance

The responsibility split is important:

  • balance owns the global wallet, topups, and ledger
  • payment.stripe owns Stripe Checkout, webhook handling, and payment-result syncing

That means the Stripe service never updates balance directly. It always credits through finishTopup().

Routes

  • GET /v1/payment/methods
  • POST /v1/payment.stripe/checkout/create
  • GET /v1/payment.stripe/payments/me
  • GET /v1/payment.stripe/payments
  • GET /v1/payment.stripe/events
  • POST /v1/payment.stripe/webhook