Stripe Payment Service

Webhooks and Sync

How Stripe webhook events, payment records, and topup completion are synchronized.

The key sync path in the payment service is:

Stripe event
  -> webhook record
  -> payment record update
  -> topup completion
  -> wallet credit

Why record the webhook separately

Stripe is the upstream source of payment truth.

Recording the webhook separately gives you:

  • a debugging trail
  • a way to rebuild payment status
  • a basis for replay or repair

So the webhook record layer is not extra ceremony. It is the fact base for payment syncing.

Enable it

base.use(new PaymentService({
  readTopup: async (topup_id) => await balance.readTopup(topup_id),
  finishTopup: async (topup_id, extra) => await balance.finishTopup(topup_id, extra),
  providers: [
    stripePaymentProvider({
      secret_key: process.env.STRIPE_SECRET_KEY,
      webhook_secret: process.env.STRIPE_WEBHOOK_SECRET,
    }),
  ],
}));

After that, Stripe should send events to:

POST /v1/payment/webhook?provider=stripe

Stripe redirect URLs are generated automatically from DOWNCITY_CITY_BASE_URL. If it is not configured, the service falls back to the current request origin and exposes two built-in result pages:

  • GET /v1/payment/redirect/success
  • GET /v1/payment/redirect/cancel

What gets synced in this version

This layer does not grant downcity access and does not mirror every Stripe object.

In this version it does four concrete things:

  • find the matching Stripe payment record
  • find the linked topup
  • call balance.finishTopup() when payment succeeds
  • mark the payment record as paid, expired, or failed

Event scope

The one-time topup flow only needs a small Stripe event surface:

  • checkout.session.completed: the required success path; this is what completes the topup
  • checkout.session.expired: marks the payment as expired
  • payment_intent.payment_failed: marks the payment as failed

Other Stripe events can be ignored until the downcity explicitly needs a new payment flow.

Idempotency rules

Webhook handling must be idempotent at two levels:

  • event idempotency: repeated Stripe event_id values should not apply twice
  • crediting idempotency: repeated success events must not credit the same topup twice

In practice, inspect the stored payment status first, then let balance.finishTopup() be the single wallet-crediting path.

Common scenarios

Scenario 1: payment succeeded but balance is still not credited

The first checks should be:

  1. did the webhook reach City?
  2. was the webhook event recorded?
  3. was the payment record updated?
  4. was the topup finished?

Scenario 2: reconciliation or manual repair

If Stripe and City look inconsistent, the webhook and payment-record layers are the first sources of truth to inspect.

Common API surface

  • POST /v1/payment/checkout/create
  • POST /v1/payment/webhook?provider=stripe
  • GET /v1/payment/payments