Stripe Setup Guide
Everything to configure inside the Stripe Dashboard and in .env to take real
payments. For the endpoints clients call see payments-api.md; for
how the engine works see payment-system.md.
Do this once for Test mode and again for Live mode β they are separate environments with separate keys, separate webhook endpoints, and separate settings. Nothing configured in test carries over to live.
1. Environment variables
STRIPE_SK=sk_test_... # Secret key
STRIPE_WEBHOOK_SECRET=whsec_... # Endpoint signing secret (step 3)
PAYMENTS_FAKE_GATEWAY=false
APP_FRONTEND_URL=https://app.meetyy.com
| Variable | Where it comes from | If missing |
|---|---|---|
STRIPE_SK |
Developers β API keys β Secret key | App silently uses the offline fake gateway |
STRIPE_WEBHOOK_SECRET |
Created in step 3 | Every webhook is rejected with 400 |
APP_FRONTEND_URL |
Your frontend origin | Redirect URLs are malformed |
Only the secret key is needed. There is no publishable key here β the buyer never enters card details in our app, they are redirected to Stripe's hosted page.
β οΈ
STRIPE_WEBHOOK_SECRETis not optional. Without it the endpoint refuses every delivery rather than skipping verification, because an unverified webhook endpoint would let anyone mark any order paid. Payments will silently never settle if you deploy with a key but no webhook secret.
After changing .env on a deployed environment:
php artisan config:clear
2. Account settings
Settings β Business β Public details
| Setting | Value | Why |
|---|---|---|
| Public business name | Meetyy | Shown on the checkout page and receipts |
| Support email / URL | your support address | Shown to buyers; reduces disputes |
| Statement descriptor | MEETYY |
What appears on the card statement. A recognisable descriptor is the single biggest lever on chargeback rate β buyers dispute charges they cannot identify |
Settings β Payments β Customer emails
Enable Successful payments and Refunds. Stripe then emails receipts directly; the app does not send payment receipts of its own.
Settings β Payment methods
Enable at minimum Cards. Enabling Apple Pay and Google Pay is free and they appear automatically on hosted checkout β worth doing, since a large share of buyers arrive from a mobile app.
Currencies must be enabled for anything you price in. The app allows usd, eur,
gbp, cad, aud, inr, bdt, jpy (App\Enums\Payment\Currency) β a course
priced in a currency your Stripe account cannot settle will fail at checkout.
3. The webhook endpoint β the critical step
Developers β Webhooks β Add endpoint
| Field | Value |
|---|---|
| Endpoint URL | https://api.meetyy.com/stripe/webhook β https://, and the API host |
| Description | Meetyy payment settlement |
| API version | Your account's default (see Β§4) |
The URL is deliberately unversioned and outside /api, so it does not move when
the client API is versioned. It must be publicly reachable over HTTPS β Stripe cannot
reach localhost (see Β§7 for local development).
β οΈ Two ways to get this URL wrong, both of which look like a broken endpoint:
http://instead ofhttps://. The server 307-redirects to HTTPS, and Stripe does not follow redirects β every delivery fails with "Temporary Redirect" and nothing ever settles. This is the single most common setup failure.- The frontend host instead of the API host.
success_urlpoints at the frontend, so it is easy to reuse that domain here. The webhook route only exists on the API host; anywhere else returns 404 or 405.Verify before trusting it β a healthy endpoint answers
400to an unsigned POST, because it is refusing the bad signature:curl -i -X POST -H 'Stripe-Signature: t=1,v1=x' -d '{}' \ https://api.meetyy.com/stripe/webhook
400= reachable and verifying.307/301= wrong scheme.404/405= wrong host.
Events to subscribe to
Select these under Select events:
| Event | What it does | Needed for |
|---|---|---|
checkout.session.completed |
Grants access. The single most important event | All surfaces |
checkout.session.expired |
Releases what was held for an abandoned buyer | All surfaces |
payment_intent.payment_failed |
Marks a declined card | All surfaces |
charge.refunded |
Syncs refunds and revokes access on a full refund | All surfaces |
invoice.paid |
Renews a subscription period | Communities |
invoice.payment_failed |
Failed renewal β dunning | Communities |
customer.subscription.updated |
Syncs period end and cancel-at-period-end | Communities |
customer.subscription.deleted |
Ends membership when a subscription lapses | Communities |
Subscribe to all eight. All three surfaces are live, and the four subscription
events are what keep community memberships in step with Stripe's billing. Do not
select "all events": that adds significant noise for no benefit. An event the app
does not handle is recorded in stripe_events as ignored and causes no error.
Copy the signing secret
After creating the endpoint, click Reveal under Signing secret and copy the
whsec_β¦ value into STRIPE_WEBHOOK_SECRET.
Each endpoint has its own signing secret. Test-mode and live-mode endpoints do not share one, and neither does the Stripe CLI (Β§7).
4. API version
This app uses stripe/stripe-php 21.2.0.
Leave the webhook endpoint on your account's default API version. Do not pin the endpoint to an older version than the account default: webhook payload shapes vary by API version, and a mismatch changes the structure of the objects the app parses.
If Stripe later prompts you to upgrade the account API version, treat it as a change requiring a test-mode run of the payment suite first β not a routine acceptance.
5. Checkout settings
Settings β Payments β Checkout and Payment Links
| Setting | Value | Why |
|---|---|---|
| Branding (logo, colours) | Your brand | The hosted page is the only part of the purchase the buyer sees outside your app |
| Enable promotion codes | On | Required before community coupons can be applied at checkout |
| Card statement descriptor | MEETYY |
See Β§2 |
Do not enable Stripe Tax without a deliberate decision. It changes the amount
charged relative to the price the app quoted and recorded in price_snapshot, which
would make the app's records disagree with Stripe's. If you need tax, that is its own
piece of work.
Do not enable "Save payment details" on one-off course or session purchases; it is only needed for subscriptions, where Stripe handles it automatically.
6. Subscription settings
Settings β Billing β Subscriptions and emails
| Setting | Recommendation | Why |
|---|---|---|
| Retry schedule on failed payment | Smart Retries, 3β4 attempts over ~2 weeks | Recovers most involuntary churn |
| After all retries fail | Cancel subscription | Fires customer.subscription.deleted, which is what ends the membership. Choosing "leave unpaid" leaves members with free access indefinitely |
| Send emails on failed payment | On | Buyer can fix their card without you building dunning |
That "cancel after retries" setting is load-bearing: the app revokes community access
on customer.subscription.deleted. If Stripe never cancels, that event never fires
and a lapsed member keeps access.
7. Local development
Without any Stripe account β leave STRIPE_SK empty. The app binds an offline
gateway and checkout_url points at a local page with Pay/Cancel buttons that drive
the same settlement path. The entire purchase flow works.
Against real Stripe β install the Stripe CLI:
stripe login
stripe listen --forward-to localhost:8000/stripe/webhook
stripe listen prints its own whsec_β¦ β put that in STRIPE_WEBHOOK_SECRET,
not the dashboard endpoint's secret. It is different, and using the wrong one makes
every webhook fail signature verification.
Trigger events by hand:
stripe trigger checkout.session.completed
stripe trigger charge.refunded
Test cards (any future expiry, any CVC):
| Number | Result |
|---|---|
4242 4242 4242 4242 |
Succeeds |
4000 0000 0000 0002 |
Declined |
4000 0000 0000 9995 |
Declined β insufficient funds |
4000 0025 0000 3155 |
Requires 3DS authentication |
8. Go-live checklist
- Live-mode secret key in
STRIPE_SK - Live-mode webhook endpoint created at
https://<api-host>/stripe/webhook - All eight events subscribed
- Live-mode signing secret in
STRIPE_WEBHOOK_SECRET(not the test one) -
PAYMENTS_FAKE_GATEWAY=false -
APP_FRONTEND_URLpoints at the production frontend -
php artisan config:clearrun after deploy - Queue worker running and consuming the
paymentsqueue β settlement happens on the queue, so payments never settle without it.STRIPE_QUEUEand the--queue=list innixpacks.tomlmust name the same queue - Confirm settlement end to end after deploy β a stalled
paymentsqueue is silent, so checkstripe_eventsshowsprocessedrather than assuming - Statement descriptor set
- Business details and support email completed (Stripe blocks live charges otherwise)
- One real end-to-end purchase made and refunded
Verifying it works
# 0. Confirm no settlement jobs are piling up unconsumed:
php artisan tinker --execute 'DB::table("jobs")->selectRaw("queue, count(*) c")->groupBy("queue")->get();'
# 1. Buy something small in live mode, then check the payment settled:
php artisan tinker --execute 'App\Models\Payment\Payment::latest()->first()->only(["status","amount_minor","paid_at"]);'
# 2. Confirm webhooks are arriving and being processed, not ignored or failed:
php artisan tinker --execute 'App\Models\Payment\StripeEvent::latest()->take(5)->get(["type","status","attempts"]);'
In the Dashboard, Developers β Webhooks β your endpoint shows every delivery and
its response. A healthy endpoint returns 200 quickly; the app acknowledges first and
settles on the queue precisely so Stripe never times out and retries.
Recovering deliveries that failed
Nothing is lost when an endpoint was misconfigured β the events still exist. After fixing the URL, replay them:
stripe events resend evt_XXXXXXXXXXXX
Or Dashboard β Developers β Events β the event β Resend. Replaying is safe: the
stripe_events unique constraint and the idempotent settlers mean a already-settled
payment is a no-op.
9. Troubleshooting
| Symptom | Cause |
|---|---|
| Dashboard shows "Temporary Redirect" (307) | Endpoint registered as http://. The server redirects to HTTPS and Stripe does not follow redirects. Re-register with https:// |
| Dashboard shows 404 or 405 | Endpoint points at the frontend host rather than the API host |
| Webhook returns 200 "Event received." but nothing settles | The worker is not consuming the payments queue. Stripe is satisfied and never retries, so this is silent β check STRIPE_QUEUE against the --queue= list in nixpacks.toml, then recover as below |
| Buyer paid, nothing unlocked | As above, or STRIPE_WEBHOOK_SECRET wrong/missing |
All webhooks show 400 in the Dashboard |
Signing secret mismatch β CLI secret used in production, or test secret in live mode |
checkout_url is a local /fake-checkout/... link |
STRIPE_SK empty or PAYMENTS_FAKE_GATEWAY=true |
Webhooks show 500 and retry |
Settlement threw. Check stripe_events.error and the log; Stripe will redeliver |
stripe_events rows stuck at received |
Job dispatched but never ran β no queue worker |
Events recorded as ignored |
Normal for event types the app does not handle |
| Checkout rejected on expiry | Domain deadline outside Stripe's 30-minuteβ24-hour window. The gateway clamps into range, so this should not occur |
Where to look
stripe_eventsβ every delivery, its status, attempts, and errorpaymentsβ the money ledger;statusandpaid_attell you if settlement ran- Application log β payment code logs directly (never via
log_entry(), which is a no-op whenAPP_DEBUG=false) - Dashboard β Developers β Webhooks β endpoint β delivery attempts
Nothing is lost by a temporary failure: Stripe retries for up to three days, the
stripe_events unique constraint makes a redelivery safe, and every settler is
idempotent. A webhook outage is recoverable by resending events from the Dashboard.
Events that arrived but never settled
A stopped worker, or a queue nobody consumes, leaves rows in stripe_events stuck at
received. Stripe considers those delivered and will not resend them on its own.
β οΈ Resending from the Dashboard alone does not fix them. stripe_events.stripe_event_id
is unique, so a resent event is recognised as a duplicate and skipped β that guard is
what makes normal retries safe, but here it blocks recovery. Delete the stuck row
first, then resend:
php artisan tinker --execute '
App\Models\Payment\StripeEvent::where("status", "received")->delete();
'
# then: Dashboard β Developers β Events β Resend (or `stripe events resend evt_β¦`)
Nothing detects a stalled settlement queue automatically, so stripe_events is worth
checking after any deploy that touches the worker.