Payments
Stripe
Take one-time payments with Stripe Checkout in VitNode - install @vitnode/stripe, configure test keys, forward webhooks with the Stripe CLI and check a real test-mode payment.
@vitnode/stripe connects VitNode payments to
Stripe Checkout, Stripe's hosted
payment page. This guide sets it up in test mode, so no real money moves, and
ends with a test purchase through the payments-demo plugin.
Before you begin
- A VitNode app with a database. The VitNode repository's
apps/webalready includes thepayments-demoplugin used in the check at the end. - A Stripe account. Everything below uses its test mode (a sandbox).
- The Stripe CLI, to forward webhooks to your machine.
Set up Stripe
Install the adapter
The stripe SDK comes with the adapter.
bun i @vitnode/stripeRegister a provider instance
Add the instance to payments.providers in your API config. Its id is stored
on every payment and is the last segment of its webhook URL, so pick it once:
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { StripePaymentAdapter } from '@vitnode/stripe'
export const vitNodeApiConfig = buildApiConfig({
payments: {
providers: [
StripePaymentAdapter({
id: 'stripe-main',
secretKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
}),
],
},
})The adapter refuses to start without both secrets, rather than running half
configured. To keep the app bootable on machines without Stripe keys, add the
instance only when STRIPE_SECRET_KEY is set, as apps/web does.
Add the secret key
In the Stripe Dashboard, switch to test mode and copy the secret key, which
starts with sk_test_. A restricted key (rk_test_) with write access to
Checkout Sessions works too.
STRIPE_SECRET_KEY=sk_test_your_keyThe key decides the mode: sk_test_ takes test payments and accepts only
test-mode webhooks; sk_live_ the opposite. A webhook from the other mode is
answered 400.
Forward webhooks with the Stripe CLI
Log in once, then forward the four Checkout events to your instance's webhook
URL. The URL is {VITNODE_API_URL}/api/@vitnode/core/payments/webhooks/{id}:
stripe login
stripe listen \
--events checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,checkout.session.expired \
--forward-to localhost:3000/api/@vitnode/core/payments/webhooks/stripe-mainstripe listen prints a signing secret starting with whsec_. Put it in
.env, then restart the app:
STRIPE_WEBHOOK_SECRET=whsec_from_stripe_listenKeep stripe listen running while you test. Its secret differs from the one a
Dashboard endpoint gets, so production uses its own value.
Create the tables
Payments add the core_payments and core_payments_webhook_events tables,
and the demo plugin adds payments_demo_purchases. From the workspace root,
generate and apply the migration:
bun run db:migratedb:migrate writes the migration in the app that owns the database and applies
it; commit that folder. pnpm dev does the same when it starts. See
database for the details.
Check the result
- Sign in, open
/payments-demoand select Buy now. You land on Stripe's hosted page with a $5.00 item. - Pay with the test card
4242 4242 4242 4242, any future expiry date and any CVC. Stripe sends you back to/payments-demo/purchases/{id}. - The page shows Payment confirmed once the webhook is processed, usually
within seconds.
stripe listenlogs a200response forcheckout.session.completed. - Check a failure: start another purchase as a different member and pay with
4000 0000 0000 0002. Stripe declines the card on its page and the session stays open, so VitNode keeps the paymentpending, notfailed.
A redelivered event answers {"outcome":"duplicate"} and changes nothing, so
resending one from the Dashboard is always safe.
Events from stripe trigger are answered {"outcome":"ignored"}: they
describe sessions VitNode did not create, so they carry no VitNode metadata.
Test through the demo instead.
How Stripe events map to statuses
| Stripe event | Session state | VitNode status |
|---|---|---|
checkout.session.completed | payment_status: "paid" | succeeded |
checkout.session.completed | payment_status: "unpaid" | processing |
checkout.session.async_payment_succeeded | paid | succeeded |
checkout.session.async_payment_failed | delayed payment failed | failed |
checkout.session.expired | closed without payment | expired |
Delayed payment methods, such as bank debits, complete the session before the
money settles. The customer sees Payment processing until Stripe sends the
second event, which can take days. Card payments are paid at completion.
Every other event type is acknowledged and ignored, as are sessions created by another instance that shares the Stripe account.
Options
Prop
Type
The adapter pins Stripe API version STRIPE_API_VERSION (exported from
@vitnode/stripe), the version of its stripe SDK. Choose the same version
for a Dashboard webhook endpoint.
Going live
- Create a webhook endpoint in the Dashboard for
https://your-site.example/api/@vitnode/core/payments/webhooks/stripe-mainwith the four events above, and use its signing secret. - Use live keys only in production, and test keys everywhere else. A second instance id per account keeps test and live data apart in one database.
- Run a cron adapter or external cron so
reconcile-paymentscan recover missed webhooks. - The webhook route spends only the safety cap, not the per-IP write budget, so a burst of Stripe deliveries is not refused. Its signature check is what keeps other callers out.
A green test purchase shows the wiring works. It does not prove your Stripe account, payment methods, tax duties or live webhook endpoint are ready.
Troubleshooting
| Symptom | Cause |
|---|---|
API does not start: needs secretKey | STRIPE_SECRET_KEY missing or not an sk_/rk_ key |
API does not start: needs webhookSecret | STRIPE_WEBHOOK_SECRET missing or not a whsec_ secret |
Webhook answers 400 | Wrong STRIPE_WEBHOOK_SECRET, a body changed by a proxy, or a live event on test keys |
Webhook answers 404 | The last URL segment is not a configured instance id |
Buy answers provider_unavailable | Stripe unreachable or the key was rejected; the API log names the Stripe error |
| Page stays on Waiting for payment | No webhook arrived; check stripe listen. The page re-checks Stripe every 15 seconds |