Module 2: Stripe Deep Dive
This is the technical core: the dashboard, API keys, test mode, the Products and Prices API, and webhooks with signature verification. By the end you will have working test-mode code that creates products and listens for payment events.
Dashboard tour: where everything lives
Log in to dashboard.stripe.com and you will see four neighborhoods. Learn them now and you will stop clicking in circles later.
Payments is the money view: every charge, refund, and dispute, searchable by customer, amount, or date. When a customer says "I was charged twice," you come here first, find both payments, and issue the refund in two clicks. The dispute queue also lives here — each dispute shows the reason code, the deadline to respond (usually about a week), and exactly what evidence wins that dispute type.
Customers is your CRM-lite: every customer object with their payment methods, subscriptions, invoices, and full payment history. When someone emails "cancel my subscription," you find them here, open the subscription, and cancel — no code, no API. You can also add a coupon, change a price, or update a card on file from this screen. Support tasks live here; engineering tasks do not.
Billing is the recurring-revenue engine: products, prices, subscriptions, invoices, and the customer portal configuration. This is where you define what you sell (Module 2, lesson 4 covers the API side). The invoicing tool here sends hosted invoice pages and handles retries on failed subscription payments automatically — configure the retry schedule (smart retries over about three weeks is the default) before your first subscriber ever fails a payment.
Developers is mission control for everything technical: API keys, webhooks, events log, and the workbench. The events log is the single most underused debugging tool in Stripe — every API request your integration makes appears here with the full request and response, so when your code fails you can see exactly what Stripe received versus what you thought you sent. Get in the habit of checking Developers > Logs before you blame your code.
One navigation habit to build: when something goes wrong with money, start at Payments (what happened), move to Customers (who it happened to), and finish at Developers > Logs (why your system did what it did). That order resolves ninety percent of payment mysteries without touching code.
A subscriber's card fails and you want to see exactly what your server sent to Stripe in that failed request. Where do you look?
Your frontend checkout suddenly charges real cards during development. What's the most likely cause?
API keys: publishable, secret, and restricted — and where each one lives
Stripe gives you four keys that look similar and do dangerously different things. Confuse them and you either break your checkout or hand out the keys to your vault.
Publishable keys (pk_test_... / pk_live_...) are safe to expose. They go in your frontend JavaScript — Stripe.js, Checkout, Elements — and they can only do safe things like tokenizing cards and creating payment intents from the client. A publishable key in your public HTML is expected, not a leak. The pk_test variant talks to test mode; pk_live talks to real money.
Secret keys (sk_test_... / sk_live_...) are the master keys. They can refund charges, move money, read every customer, and delete anything. They live only on your server — in environment variables, never in frontend code, never in a git repo, never in a client-side bundle. If a secret key ever appears in browser-accessible code, rotate it immediately (Developers > API keys > roll key) and treat it as compromised until proven otherwise.
Restricted keys are the professional answer to "my contractor needs API access." You create one at Developers > API keys > Create restricted key and grant only the permissions the job needs — say, read-only on customers and write on invoices, nothing else. If it leaks, the blast radius is the permissions you granted, not your whole account. Default to restricted keys for every integration, plugin, and teammate; reserve the full secret key for your own core server.
Test vs live: the dashboard toggle in the top area switches your entire view between test mode (simulated money, test keys) and live mode (real money, live keys). The keys are different strings — sk_test_ does nothing against live data and vice versa. The classic integration bug is deploying with test keys and wondering why real payments fail, or worse, developing against live keys and creating real charges. Check which mode you are in before every deploy: the dashboard literally changes color and labels to tell you.
Rotation: rotate secret keys on a schedule (quarterly is sane) and immediately after any suspected exposure or team change. Stripe lets you roll a key while keeping the old one active briefly — update your server env vars, verify, then expire the old key. Never rotate by editing code; rotate by changing the environment variable your code reads.
You create a price with unit_amount=2900 and currency=usd. What does the customer get charged?
Scenario: your webhook endpoint returns 200 on every event, but orders aren't fulfilling. What's the FIRST thing you check?
Products and Prices: real code, curl and Node
In Stripe, a Product is what you sell ("60-Day Shred Program") and a Price is how you sell it ($149 one-time, or $29/month recurring). One product can have many prices — monthly versus annual, USD versus EUR. You create these once via the API (or dashboard at Billing > Product catalog), then reference the price ID everywhere: checkout sessions, subscriptions, invoices.
The critical detail: unit_amount is in the smallest currency unit — cents for USD. $149.00 is 14900, not 149. Passing 149 charges $1.49, and this mistake has launched more accidental penny-sales than any other Stripe bug. For recurring prices, recurring.interval is month, year, week, or day.
Create a product with curl:
curl https://api.stripe.com/v1/products \
-u sk_test_YOUR_KEY: \
-d name="60-Day Shred Program" \
-d "metadata[program]"="shred-60"The response includes the product ID (prod_...). Now attach a one-time price:
curl https://api.stripe.com/v1/prices \
-u sk_test_YOUR_KEY: \
-d product=prod_XXX \
-d unit_amount=14900 \
-d currency=usdAnd a recurring monthly price on the same product:
curl https://api.stripe.com/v1/prices \
-u sk_test_YOUR_KEY: \
-d product=prod_XXX \
-d unit_amount=2900 \
-d currency=usd \
-d "recurring[interval]"=monthThe same flow in Node.js with the official library:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const product = await stripe.products.create({
name: '60-Day Shred Program',
metadata: { program: 'shred-60' },
});
const oneTime = await stripe.prices.create({
product: product.id,
unit_amount: 14900, // $149.00 — cents, not dollars
currency: 'usd',
});
const monthly = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00/month
currency: 'usd',
recurring: { interval: 'month' },
});
console.log(oneTime.id, monthly.id); // price_... — save theseSave the returned price_... IDs in your own database or config — hardcoding them is fine for v1, but keep a mapping table (your plan name → price ID) so swapping prices later does not mean a code deploy. Archive old prices instead of deleting them: existing subscriptions reference price IDs, and deleting a price that a live subscription points at creates exactly the mess you would expect. Archived prices stay visible in the dashboard for reference but can never be attached to anything new.
Two more habits that pay off. First, give every price a lookup_key — a stable string like shred_onetime — and resolve prices by lookup key in your code instead of pasting price_... IDs. When you eventually change the dollar amount, you create a new price with the same lookup key and your code keeps working. Second, build every product in test mode first and run a full purchase against it with the test cards from lesson 4 before you recreate it in live mode. Products and prices do not transfer between modes; you rebuild them, and the rebuild is where typos in unit_amount love to hide. A five-minute test purchase catches a $1.49-instead-of-$149 error before a customer does.
Your webhook signature verification keeps failing even though the secret is correct. What's the most likely bug?
Webhooks: your server's ears, wired correctly
Your checkout can succeed while your business never hears about it. Webhooks fix that: Stripe POSTs a JSON event to a URL you register every time something important happens — a payment succeeds, a subscription renews, a dispute opens. Your endpoint verifies it really came from Stripe, then fulfills the order. Without them you are polling Stripe asking "did anyone pay yet?" — fragile and wrong at scale.
Setup: Developers > Webhooks > Add endpoint. Paste your HTTPS URL, pick your events, and copy the signing secret (whsec_...) into your server env vars — it is shown once. Subscribe to payment_intent.succeeded (fulfill the order), checkout.session.completed (Checkout purchases), invoice.payment_failed (dunning starts here), customer.subscription.deleted (revoke access), and charge.dispute.created (gather evidence now).
Signature verification, precisely: every webhook POST carries a Stripe-Signature header containing a timestamp t and one or more signatures v1. Stripe computes HMAC-SHA256 over {timestamp}.{raw_request_body} with your whsec_ secret; your server recomputes and compares. Three rules make or break this: (1) verify against the raw request body — the exact bytes Stripe sent; (2) parse JSON after verification, since parsing re-serializes the body and breaks the signature; (3) reject events older than 300 seconds to kill replay attacks. In Express, use express.raw({type: 'application/json'}) on the webhook route, not express.json().
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
app.post('/webhook',
express.raw({ type: 'application/json' }), // raw body FIRST
(req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === 'checkout.session.completed') {
fulfillOrder(event.data.object); // your fulfillment logic
}
res.status(200).send('ok');
});Local testing: run stripe login, then stripe listen --forward-to localhost:4242/webhook. The CLI prints a session signing secret — use it as your local STRIPE_WEBHOOK_SECRET. Fire test events with stripe trigger checkout.session.completed and watch your endpoint respond without deploying.
Test cards and error codes (test mode only): 4242 4242 4242 4242 always succeeds; 4000 0000 0000 0002 is always declined (card_declined — show "try another card," never the raw code); 4000 0000 0000 3220 forces 3D Secure; 4000 0000 0000 9995 fails with insufficient funds (suggest a different card or a later retry). More codes you will meet: expired_card (prompt for the new expiry — or enable Stripe's automatic card updater); incorrect_cvc (let them retry, but rate-limit attempts since repeated failures can signal a stolen card); processing_error (Stripe's side hiccuped — safe to retry the same PaymentIntent with an idempotency key so the retry cannot double-charge). Any future expiry date and any CVC work in test mode.
A test payment fails with code processing_error. What's the right move?