Stripe Backend: Production-Ready Payments on Hostinger with Node.js
Integrating Stripe into a Node.js backend sounds straightforward until you hit the details: webhook signature verification, idempotent event handling, correct raw body capture, and deploying to shared hosting without native compilation. This project solves all of it in a minimal, well-documented package targeting Hostinger shared hosting with Node โฅ 22.
The backend uses Node's built-in node:sqlite module โ no npm database driver, no native build step. Every Stripe event is recorded in an append-only audit log. The full test suite runs with Node's built-in test runner. Nothing exotic.
What the backend covers
The project handles the complete Stripe Checkout lifecycle: creating sessions, processing webhook events, persisting orders, and providing read endpoints for inspecting what happened. It's structured for real deployment, not just tutorial code.
pending order in SQLite. Returns the hosted Stripe checkout URL.{ "status": "ok" }. Used to verify the Hostinger deployment is alive.Webhook event handling
One of the trickier parts of a Stripe integration is handling all the events correctly, including retries. The backend processes 9 event types and uses the Stripe event ID as a UNIQUE constraint in SQLite โ duplicate deliveries are detected and silently acknowledged without processing twice.
| Event | DB action |
|---|---|
| checkout.session.completed | Creates order if needed, marks paid |
| checkout.session.expired | Updates order status to expired |
| payment_intent.succeeded | Logged โ order already set by session event |
| payment_intent.payment_failed | Logged + console alert |
| payment_intent.canceled | Logged |
| charge.succeeded | Logged |
| charge.failed | Logged + console alert |
| charge.refunded | Updates order status to refunded |
| charge.dispute.created | Logged + console warn |
Database design
Two tables, each with a clear purpose. orders tracks the lifecycle of each Checkout Session โ one row per session, status transitions from pending through paid, expired, or refunded. payment_events is an append-only audit log of every webhook received, including the full JSON payload.
-- orders: one row per Stripe Checkout Session id TEXT PK -- cs_... session id payment_intent TEXT -- pi_... (set after webhook) customer_email TEXT amount_total INTEGER -- in cents currency TEXT status TEXT -- pending โ paid / expired / refunded stripe_metadata TEXT -- JSON blob created_at TEXT updated_at TEXT -- payment_events: append-only audit log id INTEGER PK AUTOINCREMENT stripe_event_id TEXT UNIQUE -- idempotency key event_type TEXT related_object_id TEXT payload TEXT -- full Stripe JSON processed_at TEXT
The stripe_event_id UNIQUE constraint is doing real work here. When Stripe retries a webhook delivery (which it will), the insert fails with a constraint violation, the handler catches it and returns { received: true, duplicate: true }, and no order state is corrupted.
Project structure
stripe-backend/ โโโ config/ โ โโโ stripe.js # Stripe client + env validation โโโ db/ โ โโโ database.js # Schema, connection, query helpers โโโ middleware/ โ โโโ rawBody.js # Raw body capture for webhook HMAC โโโ routes/ โ โโโ checkout.js # Create session, /success, /cancel โ โโโ orders.js # Read-only order/event endpoints โ โโโ webhook.js # All Stripe event handlers โโโ tests/ โ โโโ database.test.js # 27 DB layer unit tests โ โโโ webhook.test.js # Webhook handler integration tests โโโ public/ โ โโโ index.html # Quick browser test page โโโ server.js โโโ .env.example
Configuring Stripe keys
Stripe gives you two environments โ Sandbox (formerly Test mode) and Live โ with completely separate keys and webhook endpoints. The project includes a dedicated STRIPE_KEYS_GUIDE.md that covers every step: finding your API keys in the new Stripe Workbench UI, registering a webhook endpoint and selecting the 9 events, capturing the signing secret, configuring your .env, and setting environment variables in Hostinger's hPanel. It also covers key rotation and the security rules you can't skip.
Three environment variables is all you need to start:
# .env STRIPE_SECRET_KEY=sk_test_51... STRIPE_WEBHOOK_SECRET=whsec_... APP_BASE_URL=https://yourdomain.com CORS_ORIGINS=https://yourdomain.com PORT=3000
Never commit
.envto Git. The.gitignorealready excludes it โ verify withgit check-ignore -v .envbefore your first push.
Testing the API
The project ships with two testing tracks: an automated test suite and a curl-based manual guide. The API_TESTING_GUIDE.md walks through every endpoint with exact curl commands, expected responses for both success and error cases, and a full end-to-end payment flow from session creation to webhook confirmation.
Automated tests
The test suite uses Node's built-in node:test runner โ no Jest, no Vitest, no extra dependencies. Each test suite gets an isolated :memory: SQLite database, so nothing touches disk and tests are fully repeatable.
npm test โถ orders table (9 tests) โถ payment_events table (7 tests) โถ schema integrity (5 tests) โถ webhook: checkout.session.completed (2 tests) โถ webhook: checkout.session.expired (2 tests) โถ webhook: idempotency (2 tests) โถ webhook: charge.refunded (1 test) โถ webhook: event log audit trail (1 test) tests 34 pass 34 fail 0
Webhook testing with Stripe CLI
The webhook endpoint requires a valid Stripe signature โ you can't just curl it directly. Use the Stripe CLI to forward real sandbox events to your local server:
# Terminal 1 โ start the server npm run dev # Terminal 2 โ forward Stripe events to localhost stripe listen --forward-to localhost:3000/webhook # Terminal 3 โ trigger test events stripe trigger checkout.session.completed stripe trigger charge.refunded stripe trigger charge.dispute.created
Test cards
| Card number | Scenario |
|---|---|
| 4242 4242 4242 4242 | Payment succeeds |
| 4000 0000 0000 0002 | Card declined |
| 4000 0025 0000 3155 | 3D Secure required |
| 4000 0000 0000 9995 | Insufficient funds |
Deploying to Hostinger
The README includes a specific Hostinger deployment section because shared hosting has some non-obvious constraints. The key decisions:
Set DB_PATH to an absolute path outside the web root โ never inside public_html. The directory is created automatically on first run, but the path must be writable by your Node process. On Hostinger, something like /home/username/stripe-data/stripe.db works reliably.
Use hPanel's Environment Variables section instead of uploading a .env file via SFTP. Add each variable individually, save, and restart the Node.js app. This is safer than managing a secrets file on disk and easier to update without SSH access.
The production checklist in the README covers the full go-live sequence: switching from sandbox to live Stripe keys, registering a live webhook endpoint, locking down the /orders endpoints (no auth in the current implementation), enabling HTTPS, and setting up a SQLite backup schedule.