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 creditWhy 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=stripeStripe 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/successGET /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, orfailed
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 topupcheckout.session.expired: marks the payment as expiredpayment_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_idvalues 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:
- did the webhook reach City?
- was the webhook event recorded?
- was the payment record updated?
- 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/createPOST /v1/payment/webhook?provider=stripeGET /v1/payment/payments