Advanced Stripe: Webhooks, Subscriptions, and Syncing Payments to Supabase
Build reliable Stripe webhook handlers in n8n, manage subscription lifecycle events, handle failed payments, and keep Supabase in sync with your Stripe data.
The basics guide covered creating payments and reading the dashboard. This guide is about what happens after someone pays — or doesn’t pay. Webhooks are where Stripe stops being a payment form and becomes a full automation layer. Webhooks are what turn a payment into an event that updates your database, sends a receipt, changes access levels, and triggers the next step in your business process.
If you’re not handling webhooks, you’re using maybe 20% of what Stripe can do for you.
Stripe Webhooks: Where the Automation Starts
A Stripe webhook is a POST request that Stripe sends to a URL you control — in this case, an n8n webhook trigger — whenever a specific event happens. Payment succeeded? Stripe sends you a webhook. Subscription cancelled? Webhook. Card declined on retry? Webhook. You don’t have to poll Stripe to find out what happened. Stripe tells you the moment it happens.
Setting up a webhook endpoint in Stripe:
Go to Dashboard → Developers → Webhooks → Add Endpoint.
Enter your n8n webhook trigger URL. (Get this from the Webhook node in n8n — it looks like https://your-n8n-instance.com/webhook/stripe-events.)
Under “Events to send,” select the events you want to handle. Start with:
payment_intent.succeededpayment_intent.payment_failedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
You can always add more events later. It’s better to start with the ones you’ll actively handle than to receive every event and ignore most of them.
After creating the endpoint, Stripe shows you a Webhook Signing Secret (starts with whsec_). Copy this. You’ll use it to verify that incoming webhook requests are actually from Stripe.
Webhook Signature Verification
Every webhook request from Stripe includes a Stripe-Signature header. This header contains a timestamp and a cryptographic signature computed using your webhook signing secret. Verifying this signature before processing the webhook ensures you’re not acting on a spoofed request from someone who figured out your webhook URL.
This is not optional for production systems. An unverified webhook endpoint is a security hole.
Here’s how to verify in an n8n Code node, placed immediately after the Webhook trigger:
const crypto = require('crypto');
const payload = $input.first().json.body; // raw request body
const sigHeader = $input.first().headers['stripe-signature'];
const webhookSecret = 'whsec_your_signing_secret_here';
// Parse the signature header
const parts = sigHeader.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
const signatures = parts
.filter(p => p.startsWith('v1='))
.map(p => p.split('=')[1]);
// Compute expected signature
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const expectedSig = crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex');
// Verify
const isValid = signatures.some(sig => sig === expectedSig);
if (!isValid) {
throw new Error('Invalid Stripe webhook signature');
}
// Also check timestamp to prevent replay attacks (reject if older than 5 minutes)
const tolerance = 300; // seconds
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > tolerance) {
throw new Error('Stripe webhook timestamp too old');
}
return $input.all();
If the signature is invalid, the Code node throws an error, which stops the workflow and prevents any further processing. In n8n, configure the webhook to respond with a 200 even on error (so Stripe doesn’t retry endlessly), but log the invalid attempt.
Handling payment_intent.succeeded
This is the most important event in any transaction-based business. When this fires, money has moved. Your job is to record that fact and trigger everything that should happen next.
The payment_intent.succeeded payload includes:
data.object.id— The payment intent IDdata.object.amount— Amount in centsdata.object.currency— Currency codedata.object.customer— Stripe Customer IDdata.object.metadata— The metadata you attached when creating the payment intent (this is where yourjob_idlives)
Full n8n flow for payment success:
- Webhook Trigger receives the event
- Code node verifies the signature (as above)
- IF node checks
{{ $json.body.type === 'payment_intent.succeeded' }} - Set node extracts
job_idfrom{{ $json.body.data.object.metadata.job_id }} - HTTP Request → Supabase — Update the jobs table:
SET payment_status = 'paid', paid_at = now(), stripe_payment_intent_id = 'pi_...', amount_paid = {{ amount / 100 }} WHERE id = {{ job_id }} - HTTP Request → Supabase — Insert a row in the
paymentstable with the full payment details - HTTP Request → Supabase — Query the customer record to get their email address
- HTTP Request → Resend — Send payment receipt email with job summary and amount
Every step after signature verification is a downstream consequence of a single Stripe event. This is the automation loop — Stripe fires the event once, and n8n handles everything downstream.
Handling Failed Payments
Failed payments need a different response from successful ones. The customer needs to know, they need a way to retry, and your job record needs to reflect the current state.
n8n flow for payment_intent.payment_failed:
- Webhook receives
payment_intent.payment_failed - Signature verification
- Extract
job_idfrom metadata,customer_idfrom the payment intent - Update Supabase:
payment_status = 'failed',payment_failure_reason = {{ failure_message }},payment_retry_count = payment_retry_count + 1 - Query Supabase for customer phone number and email
- HTTP Request → Twilio — Send SMS: “Hi Maria, we weren’t able to process your payment for your HVAC repair. Please try again: [new payment link]”
- HTTP Request → Resend — Send failure notification email with the new payment link
- Create a new Stripe Payment Link for the same amount and attach it to the notification
Retry logic with a Schedule Trigger:
Create a separate n8n workflow that runs daily:
- Schedule Trigger (8 AM daily)
- HTTP Request → Supabase — Query:
SELECT * FROM jobs WHERE payment_status = 'failed' AND payment_retry_count < 3 AND last_retry_at < now() - interval '24 hours' - Loop Over Items
- For each job: create a new payment link, send reminder SMS, update
last_retry_at, incrementretry_count
Stop retrying after 3 attempts. At that point, flag the job for manual follow-up and assign it to the dispatcher’s attention queue. Some failed payments require a human conversation.
Stripe Subscriptions for SaaS
If you’re building a product with recurring revenue — a software tool, a monthly service plan, an ongoing maintenance contract — Stripe’s subscription billing handles the complexity.
Key objects:
- Product — What you’re selling (“Pro Plan,” “Monthly Maintenance”)
- Price — The billing terms for a product ($49/month, $499/year)
- Subscription — A Customer enrolled in a Price with a billing cycle
Creating a subscription from n8n:
POST https://api.stripe.com/v1/subscriptions
Body:
customer=cus_abc123
items[0][price]=price_monthly_pro
metadata[supabase_customer_id]=uuid
This creates the subscription and starts the billing cycle. Stripe handles everything from there: charges the card monthly, generates invoices, retries failed payments (this is called dunning), and fires events you can react to.
Subscription lifecycle events you must handle:
customer.subscription.created — New subscription activated:
- n8n → Supabase:
subscription_status = 'active',subscription_tier = 'pro',subscription_period_end = {{ current_period_end }}
customer.subscription.updated — Plan changed, renewal date updated, status changed:
- n8n → Supabase: update whichever fields changed (
tier,status,period_end)
customer.subscription.deleted — Subscription cancelled (either by customer or by Stripe after failed payment recovery):
- n8n → Supabase:
subscription_status = 'cancelled',subscription_tier = 'free' - Revoke access to paid features immediately
invoice.paid — Subscription successfully renewed:
- n8n → Supabase: update
subscription_period_endto the next renewal date - Log the payment in your
paymentstable - Send renewal receipt via Resend
invoice.payment_failed — Subscription renewal failed:
- n8n → Supabase:
subscription_status = 'past_due' - Send payment failure notification to customer
- Stripe will retry automatically (3 times by default, configurable in your Stripe settings)
This is how content or feature gating works on this very site. When you upgrade to a paid plan, subscription_status = 'active' gets written to Supabase. Every time you request a paid guide, the system checks that field. When a subscription lapses, the status updates and access changes — automatically, without anyone doing anything.
The Customer Portal
Stripe’s Customer Portal is a hosted page where your customers can manage their own subscriptions: update their payment method, download invoices, change their plan, or cancel. You don’t build this UI — Stripe provides it.
To give a customer access to the portal, you generate a portal session via the API:
POST https://api.stripe.com/v1/billing_portal/sessions
Body:
customer=cus_abc123
return_url=https://yourapp.com/account
The response includes a url. Redirect the customer to that URL. They land on a Stripe-hosted page, make changes, and get redirected back to your return_url when they’re done.
When customers make changes in the portal, Stripe fires the appropriate webhook events (customer.subscription.updated, customer.subscription.deleted, etc.) and your n8n handlers process them exactly as they would for API-driven changes.
The portal eliminates an entire category of UI you’d otherwise have to build — subscription management, payment method updates, invoice history. For small teams, this is a meaningful scope reduction.
Syncing Stripe to Supabase
The core principle: every Stripe event that touches money or a customer should be reflected in Supabase. Your application should be able to answer “what did this customer pay, and when?” from your own database without calling the Stripe API every time.
The payments table schema I use in Supabase:
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stripe_payment_intent_id TEXT UNIQUE NOT NULL,
stripe_customer_id TEXT NOT NULL,
customer_id UUID REFERENCES customers(id),
job_id UUID REFERENCES jobs(id),
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'usd',
status TEXT NOT NULL, -- 'succeeded', 'failed', 'refunded'
stripe_created_at TIMESTAMPTZ,
recorded_at TIMESTAMPTZ DEFAULT now()
);
Every payment_intent.succeeded webhook writes a row here. Every payment_intent.payment_failed event writes a row with status = 'failed'.
Why mirror Stripe data instead of querying Stripe directly?
- Database queries are faster than API calls
- You can join payments to your other tables (jobs, customers) in a single SQL query
- Your data is available even if Stripe has an outage or rate limits your requests
- You can run analytics and reports on your payment data without Stripe’s dashboard
The trade-off is that your Supabase data can be slightly behind Stripe’s data if a webhook fails to deliver or your n8n workflow has an error. This is why the stripe_payment_intent_id field is the source of truth — you can always reconcile by querying the Stripe API for any specific payment and comparing to your Supabase record.
Stripe CLI for Local Development
When you’re building webhook handlers, you need Stripe webhooks to arrive at your local n8n instance. The Stripe CLI solves this.
Install the Stripe CLI:
# macOS
brew install stripe/stripe-cli/stripe
# Windows
scoop install stripe
Log in:
stripe login
Forward webhooks to your local n8n:
stripe listen --forward-to localhost:5678/webhook/your-webhook-path
The CLI connects to Stripe’s servers and forwards any events from your test account to your local n8n instance in real time. You get a local webhook signing secret from the CLI output — use this in your signature verification code while developing locally.
Trigger specific events for testing:
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
This fires a test event with sample data through the CLI and into your local n8n workflow. You don’t have to create a real payment and go through the checkout flow to test your webhook handlers. You can simulate any event type and test your handling logic directly.
This is how I built the entire SendJob payment automation before it ever touched a live card. Every webhook handler was built, tested, and debugged with the Stripe CLI before the first real customer payment came through.
You’ve built the full stack: automation, database, SMS, email, and payments. One more layer to add: What Is the AI Layer? →