Stripe Integration Standard
API keys, Customer creation, Products & Prices, Payment Intents, Checkout Sessions, Subscriptions, Webhooks, Metadata, Decline codes, Refunds.
Covers every part of a Stripe integration: account setup, API keys, customers, products, payments, subscriptions, webhooks, metadata, error handling, and refunds.
1. Account Setup and API Keys
Key Types
| Key | Where Used | Exposure |
|---|---|---|
Publishable key (pk_...) |
Frontend (browser/client) | Safe to expose |
Secret key (sk_...) |
Backend only | NEVER expose to browser |
Webhook signing secret (whsec_...) |
Backend webhook handler only | NEVER expose |
Environment Variables
STRIPE_PUBLISHABLE_KEY=pk_test_... # or pk_live_...
STRIPE_SECRET_KEY=sk_test_... # or sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
- Use
pk_test_/sk_test_locally and in staging - Use
pk_live_/sk_live_in production only - Never commit any Stripe key to version control
- Rotate immediately if a secret key or webhook secret is exposed
Local Webhook Testing
Use Stripe CLI to forward webhook events to your local server:
stripe listen --forward-to localhost:8000/webhooks/stripe/
The CLI prints a temporary webhook signing secret — use it in local .env.
2. Stripe Customer
When to Create a Customer
Create a Stripe Customer:
- When a user initiates their first payment, OR
- At user sign-up if your app is payment-centric
Do NOT create a Stripe Customer at sign-up for apps where most users never pay.
What to Store
Store stripe_customer_id in your database against the user record:
ALTER TABLE users ADD COLUMN stripe_customer_id text UNIQUE;
Customer Metadata
Always set metadata on the Customer object:
stripe.Customer.create(
email=user.email,
name=user.get_full_name(),
metadata={
'user_id': str(user.id),
'workspace_id': str(workspace.id),
}
)
Never Store Card Details
Do not store raw card numbers, CVVs, or full card data. Stripe's tokenization (via Stripe.js / Elements) handles this entirely.
3. Products and Prices
Concepts
- Product: what you sell (e.g., "Pro Plan", "Enterprise Plan")
- Price: how you sell it (e.g., $29/month, $290/year, $0.01/API call)
- One product can have many prices (monthly vs annual, different currencies)
Where to Create
| Scenario | Create In |
|---|---|
| Fixed subscription tiers | Stripe Dashboard — create once, reference by price_id |
| Dynamic pricing (per-seat, usage-based) | Code — create via API at runtime |
| Marketing changes copy/price often | Dashboard — non-engineers can update |
Product Metadata
# Set when creating a product in code
stripe.Product.create(
name='Pro Plan',
metadata={
'plan': 'pro',
'feature_set': 'analytics,api,export',
}
)
Price Lookup Keys
Use lookup keys for prices so you reference them by name, not hardcoded price_id:
stripe.Price.create(
product=product.id,
unit_amount=2900, # in cents
currency='usd',
recurring={'interval': 'month'},
lookup_key='pro-monthly',
)
Then retrieve by lookup key instead of hardcoding:
prices = stripe.Price.list(lookup_keys=['pro-monthly'])
price_id = prices.data[0].id
4. One-Time Payments — Payment Intents
Flow
Client: "Start payment"
→ Server: create PaymentIntent → returns client_secret
→ Client: stripe.confirmCardPayment(client_secret)
→ Stripe: confirms payment
→ Webhook: payment_intent.succeeded → update DB
Creating a Payment Intent (server-side)
intent = stripe.PaymentIntent.create(
amount=2900, # in cents — never floats
currency='usd',
customer=user.stripe_customer_id,
metadata={
'workspace_id': str(workspace.id),
'user_id': str(user.id),
'purpose': 'pro_upgrade',
},
idempotency_key=f'{user.id}-{workspace.id}-{purpose}-{timestamp}',
)
return {'client_secret': intent.client_secret}
Rules
- Amount always in smallest currency unit (cents for USD, pence for GBP, paise for INR)
- Use integer amounts — never floats
- Return
client_secretto the frontend — never the secret key - Use idempotency keys to prevent duplicate charges on retried requests
- Activate after success via webhook (
payment_intent.succeeded), not the client-side callback
Confirming on the Client (Stripe.js)
const stripe = Stripe(PUBLISHABLE_KEY); // from env, not hardcoded
const {error} = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement }
});
if (error) {
// Show error to user — never log raw error to analytics
} else {
// Payment pending — wait for webhook to update backend
}
5. Checkout Sessions (Hosted Checkout)
Use Checkout Sessions when you want Stripe to host the payment page (simpler integration, less custom UI).
session = stripe.checkout.Session.create(
customer=user.stripe_customer_id,
payment_method_types=['card'],
line_items=[{
'price': 'price_id_here',
'quantity': 1,
}],
mode='payment', # or 'subscription'
success_url=f'{APP_BASE_URL}/payment/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{APP_BASE_URL}/payment/cancelled',
metadata={
'workspace_id': str(workspace.id),
'user_id': str(user.id),
},
)
return {'checkout_url': session.url}
Rules for Checkout Sessions
- Activate via webhook (
checkout.session.completed), not thesuccess_urlredirect — the redirect is unreliable (user might close browser before it loads) - Include
{CHECKOUT_SESSION_ID}insuccess_urlso you can verify if needed - Set metadata on the session for webhook processing
6. Subscriptions
Creating a Subscription
subscription = stripe.Subscription.create(
customer=user.stripe_customer_id,
items=[{'price': price_id}],
trial_period_days=14, # optional
metadata={
'workspace_id': str(workspace.id),
'plan': 'pro',
},
)
Subscription States
| Status | Meaning | Action |
|---|---|---|
active |
Paying and active | Grant access |
trialing |
In free trial | Grant access |
past_due |
Payment failed, retrying | Warn user, restrict non-critical features |
canceled |
Ended | Revoke access |
unpaid |
All retries failed | Restrict access |
incomplete |
First payment pending | Do not grant access yet |
Handling Subscription Lifecycle via Webhooks
# Events to handle:
'customer.subscription.created' → provision access
'customer.subscription.updated' → update plan/status in DB
'customer.subscription.deleted' → revoke access
'invoice.payment_succeeded' → record payment, send receipt
'invoice.payment_failed' → notify user, update payment status
7. Webhooks
Why Webhooks, Not Client Callbacks
Client-side callbacks (success page redirect) are unreliable — the user might close the browser, lose connection, or the callback might never fire. Always use webhooks as the source of truth for payment state.
Signature Verification (Required, Always)
import stripe
from django.http import HttpResponse
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
return HttpResponse(status=400) # Invalid payload
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400) # Invalid signature
handle_event(event)
return HttpResponse(status=200)
Idempotency — Process Each Event Once
def handle_event(event):
# Check if already processed
if StripeEvent.objects.filter(event_id=event['id']).exists():
return # Skip duplicate
StripeEvent.objects.create(event_id=event['id'], type=event['type'])
if event['type'] == 'payment_intent.succeeded':
handle_payment_succeeded(event['data']['object'])
elif event['type'] == 'customer.subscription.deleted':
handle_subscription_cancelled(event['data']['object'])
# ...
Webhook Response Rules
- Always return 200 after receiving the event (even if you skip processing it)
- Never return 500 for business logic failures — log the error and return 200
- Stripe retries on 4xx/5xx responses — returning 500 causes duplicate processing
Events to Handle
| Event | Action |
|---|---|
payment_intent.succeeded |
Activate order/access, send receipt |
payment_intent.payment_failed |
Notify user, do not activate |
checkout.session.completed |
Activate purchase |
customer.subscription.created |
Provision plan access |
customer.subscription.updated |
Sync plan changes to DB |
customer.subscription.deleted |
Revoke access after period ends |
invoice.payment_succeeded |
Record payment in billing history |
invoice.payment_failed |
Email user, mark payment as failed |
8. Metadata Standards
Consistent metadata keys across all Stripe objects:
| Object | Required Metadata Keys |
|---|---|
| Customer | user_id, workspace_id |
| PaymentIntent | workspace_id, user_id, purpose |
| Checkout Session | workspace_id, user_id, plan (if subscription) |
| Subscription | workspace_id, plan |
| Invoice | auto-inherited from subscription |
Rules
- Use
snake_casefor all metadata keys - Values must be strings (convert UUIDs and integers to
str()) - Metadata values max 500 characters, max 50 keys
- Do NOT store sensitive data (passwords, secrets, PII beyond what Stripe already has) in metadata
9. Error Handling
Decline Codes
| Code | User Message |
|---|---|
card_declined |
"Your card was declined. Please try a different card." |
insufficient_funds |
"Your card has insufficient funds." |
incorrect_cvc |
"Your card's security code is incorrect." |
expired_card |
"Your card has expired." |
processing_error |
"An error occurred. Please try again." |
Never show raw Stripe error messages to users. Map decline codes to user-friendly messages.
3D Secure / SCA (Strong Customer Authentication)
const {paymentIntent, error} = await stripe.confirmCardPayment(clientSecret);
if (error?.payment_intent?.status === 'requires_action') {
// Stripe.js handles the 3DS challenge automatically with confirmCardPayment
// Show user: "Your bank requires additional verification."
}
Network / API Errors
try:
intent = stripe.PaymentIntent.create(...)
except stripe.error.CardError as e:
# Decline — show decline message
return {'error': e.user_message}
except stripe.error.RateLimitError:
# Too many requests — retry later
raise
except stripe.error.InvalidRequestError:
# Invalid parameters — log, fix code
raise
except stripe.error.StripeError:
# General error — log, show generic error
raise
10. Refunds
refund = stripe.Refund.create(
payment_intent=payment_intent_id,
amount=500, # partial refund in cents; omit for full refund
reason='requested_by_customer', # or 'fraudulent', 'duplicate'
metadata={
'refunded_by': str(admin_user.id),
'reason_detail': 'Customer reported non-delivery',
},
)
Rules
- Always refund via API — never manually in the Stripe Dashboard (for consistency with your DB records)
- Store
refund_idin your database - Update order/payment status in your DB after successful refund
- Confirm via webhook
charge.refundedbefore marking as fully refunded in DB - Partial refunds: specify
amountin cents; omit for full refund