SaaSyBase
SaaSyBase

Webhooks

Webhooks are how SaaSyBase learns about completed checkouts, renewals, refunds, failed payments, user creation, organization changes, and more. The app supports both centralized and provider-specific webhook routes.

Note

Plain-English version: a webhook is a message your payment or auth provider sends back to SaaSyBase after something important happens. Without it, a checkout can succeed on the provider side while your app still does not know it should unlock the subscription.

Centralized payment webhook ingress

The preferred payment webhook endpoint is /api/webhooks/payments. It inspects the incoming signature header, identifies the provider, verifies the signature, and routes the event through the shared payment service.

HeaderProviderAlias routes
stripe-signatureStripe/api/webhooks/stripe and /api/stripe/webhook
x-paystack-signaturePaystack/api/webhooks/paystack
paddle-signaturePaddle/api/webhooks/paddle
x-razorpay-signatureRazorpayNo dedicated alias route is currently shipped

Note

All four shipped payment providers are handled by the centralized router. Only Stripe, Paystack, and Paddle currently expose extra provider-specific alias routes.

Razorpay is fully supported through the centralized endpoint. It simply does not ship a dedicated /api/webhooks/razorpay alias route.

What payment webhooks drive

  • Checkout completion and subscription activation
  • Recurring renewals and invoice payment success
  • Payment failures and cancellation scheduling
  • Refunds, disputes, and receipt generation
  • Provider-specific follow-up flows like Paystack or Razorpay reconciliation

All of that normalizes into the provider-agnostic payment abstraction in lib/payment.

The table below is the recommended event checklist for all four shipped payment providers, not only Stripe.

ProviderRecommended events to enable
Stripecheckout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, invoice.payment_succeeded, invoice.payment_failed, invoice.upcoming, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, charge.refunded, charge.dispute.created, charge.dispute.updated, charge.dispute.closed
Paystackcharge.success, subscription.create, subscription.not_renew, subscription.disable, invoice.create, invoice.update, invoice.payment_failed, refund.processed
Paddletransaction.completed, subscription.created, subscription.updated, transaction.payment_failed, adjustment.created, adjustment.updated
Razorpaypayment_link.paid, payment.captured, payment.failed, refund.processed, payment.refunded, subscription.activated, subscription.updated, subscription.cancelled, subscription.halted

Signature verification and rotation

Webhooks are processed only after signature verification succeeds. Secrets support rotation using comma-separated values.

STRIPE_WEBHOOK_SECRET="whsec_new,whsec_old"
PAYSTACK_WEBHOOK_SECRET="rotated_secret"   # Optional; falls back to PAYSTACK_SECRET_KEY
PADDLE_WEBHOOK_SECRET="pdl_new,pdl_old"

Warning

Never disable signature verification in production. If a provider does not offer a dashboard-level test send, use their CLI or sandbox tools instead.

A signature failure is treated as a rejected delivery, not a partially processed event. Fix the secret mismatch first, then re-send the event from the provider if you need the business action to occur.

Local webhook testing

For local development, your goal is simple: make one real or simulated provider event reach your local app and confirm the app state changes. Do not try to perfect every webhook before you can prove one full round-trip works.

ProviderPractical local test path
StripeUse the Stripe CLI and forward events to localhost.
PaystackUse the Paystack dashboard test mode or a reachable HTTPS dev URL and confirm the signature header matches your secret setup.
PaddleUse Paddle sandbox plus a reachable HTTPS URL such as ngrok or your local HTTPS host.
RazorpayUse test mode and a reachable HTTPS URL so Razorpay can deliver back to your local app.
ClerkUse the Clerk dashboard webhook tester against a reachable public or local HTTPS URL.
Stripe local example
stripe listen --forward-to http://localhost:3000/api/webhooks/stripe

If you use a custom localhost domain or tunnel during development, make sure the exact host is allowed by your local setup and that the provider is sending to that exact URL, not a stale older one.

Common webhook problems

ProblemUsual causeFix
Provider says delivery failedThe URL is wrong, unreachable, or returning a non-2xx statusOpen the endpoint URL directly, confirm the domain is live, and verify the provider dashboard points at the exact current route.
Signature verification failsThe secret in your env does not match the provider endpoint secretRe-copy the provider secret, restart the app if needed, and re-send the event.
Webhook returns 200 but the app state does not changeThe event type sent is not one the app uses for that business actionEnable the recommended event list for your provider and re-test with a relevant event.
Local testing never reaches your appThe provider cannot call plain localhost from the public internetUse the provider CLI, a tunnel, or a local HTTPS hostname that is actually reachable from the provider.

Clerk webhook

/api/webhooks/clerk keeps the local database in sync with Clerk users and organizations.

Event familyWhat happens
user.createdCreates the local user record and initializes starter state
user.updatedSyncs profile and verification state; can trigger welcome flow once verified
organization.*Upserts organizations into the local database
organizationMembership.*Syncs membership roles and statuses
organizationInvitation.*Tracks invite state changes

Configure Clerk to send events to the production URL and set CLERK_WEBHOOK_SECRET.

Provider setup checklist

  1. Deploy the app and verify the public domain works.
  2. Create the webhook endpoint in the provider dashboard.
  3. Copy the provider secret into your environment variables.
  4. Trigger a test event and verify the subscription, payment, or user state actually changes in the app.
  5. Check logs or the API reference page if verification fails.