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
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.
| Header | Provider | Alias routes |
|---|---|---|
| stripe-signature | Stripe | /api/webhooks/stripe and /api/stripe/webhook |
| x-paystack-signature | Paystack | /api/webhooks/paystack |
| paddle-signature | Paddle | /api/webhooks/paddle |
| x-razorpay-signature | Razorpay | No dedicated alias route is currently shipped |
Note
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.
| Provider | Recommended events to enable |
|---|---|
| Stripe | checkout.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 |
| Paystack | charge.success, subscription.create, subscription.not_renew, subscription.disable, invoice.create, invoice.update, invoice.payment_failed, refund.processed |
| Paddle | transaction.completed, subscription.created, subscription.updated, transaction.payment_failed, adjustment.created, adjustment.updated |
| Razorpay | payment_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
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.
| Provider | Practical local test path |
|---|---|
| Stripe | Use the Stripe CLI and forward events to localhost. |
| Paystack | Use the Paystack dashboard test mode or a reachable HTTPS dev URL and confirm the signature header matches your secret setup. |
| Paddle | Use Paddle sandbox plus a reachable HTTPS URL such as ngrok or your local HTTPS host. |
| Razorpay | Use test mode and a reachable HTTPS URL so Razorpay can deliver back to your local app. |
| Clerk | Use the Clerk dashboard webhook tester against a reachable public or local HTTPS URL. |
stripe listen --forward-to http://localhost:3000/api/webhooks/stripeIf 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
| Problem | Usual cause | Fix |
|---|---|---|
| Provider says delivery failed | The URL is wrong, unreachable, or returning a non-2xx status | Open the endpoint URL directly, confirm the domain is live, and verify the provider dashboard points at the exact current route. |
| Signature verification fails | The secret in your env does not match the provider endpoint secret | Re-copy the provider secret, restart the app if needed, and re-send the event. |
| Webhook returns 200 but the app state does not change | The event type sent is not one the app uses for that business action | Enable the recommended event list for your provider and re-test with a relevant event. |
| Local testing never reaches your app | The provider cannot call plain localhost from the public internet | Use 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 family | What happens |
|---|---|
| user.created | Creates the local user record and initializes starter state |
| user.updated | Syncs 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
- Deploy the app and verify the public domain works.
- Create the webhook endpoint in the provider dashboard.
- Copy the provider secret into your environment variables.
- Trigger a test event and verify the subscription, payment, or user state actually changes in the app.
- Check logs or the API reference page if verification fails.

