Paystack Production Validation
Use this runbook before enabling Paystack in production and during the first launch window.
Required environment
Set these on the backend deployment:
PAYMENT_ENABLED=true
PAYMENT_PROVIDER=paystack
PAYSTACK_SECRET_KEY=<live-or-test-secret-key>
PAYMENT_WEBHOOK_RETENTION_DAYS=90
FRONTEND_BASE_URL=https://<app-domain>Store PAYSTACK_SECRET_KEY in a secrets manager such as AWS Secrets Manager, HashiCorp Vault, or Kubernetes Secrets. Never commit secrets to version control. Use Paystack test keys for staging and live keys only in production; rotate the key immediately if it is exposed.
PAYMENT_WEBHOOK_RETENTION_DAYS controls raw Paystack webhook payload retention. The default is 90 days and the application caps it at 365 days.
Keep PAYMENT_PLAN_SYNC_ENABLED=false in production unless the release is intentionally creating missing Paystack plans from the DB plan catalog. The normal production model is:
- Create or review plans in Paystack.
- Store the corresponding
paystack_plan_codeon each paidlookup.subscription_planrow. - Deploy with plan sync disabled.
Billing terms and provider prices
A plan tier is one row. Each term a tier can be sold at is a row in lookup.subscription_plan_provider_prices, keyed by (subscription_plan_id, provider, billing_interval) and carrying the external_price_id (the PLN_... code), the currency, and the amount_minor that term actually costs. Migration 074 moves any legacy *_ANNUAL plan row's Paystack plan onto its tier as an annually price row, so an upgraded database keeps its annual offering without manual work. A database that never had annual plans will only offer monthly until an annually price row is added.
Paystack dashboard
Configure the webhook URL:
https://<api-domain>/api/v1/core/webhooks/paystackDashboard checklist:
- Confirm live mode is enabled only for the production Paystack business.
- Confirm all paid Ovahol plans exist in Paystack and their plan codes are stored in
lookup.subscription_plan_provider_prices.external_price_idfor the matchingbilling_interval(the legacylookup.subscription_plan.paystack_plan_codeis only a monthly fallback). - Confirm the webhook URL points to the production API domain over HTTPS.
- Confirm the webhook secret matches
PAYSTACK_SECRET_KEY. - Confirm payment channels are intentionally enabled for the target market.
Test-mode validation
Run these checks in Paystack test mode before using live keys:
- Start a paid-plan checkout from the app.
- Complete the Paystack test payment.
- Confirm the frontend returns to
/billing/callback?reference=.... - Confirm the organization becomes
ACTIVE. - Confirm
core_public.billing_transactionshas aSUCCESSrow for theovh_...reference with a non-nulluser_id. - Confirm Paystack sends
subscription.create, and the organization storessubscription_codeandemail_token. - Trigger or simulate a renewal
charge.successfor the subscription. - Confirm the renewal transaction has
plan_code = 'RENEWAL',status = 'SUCCESS', anduser_id IS NULL. - Re-deliver the same renewal webhook.
- Confirm there is still only one transaction row for that Paystack reference.
- Trigger or simulate a paid
invoice.update(Paystack's renewal signal). Confirm the organization isACTIVEand that no extrabilling_transactionsrow is created —charge.successowns the revenue trail, and the invoice path only restores entitlement. - Trigger or simulate
invoice.payment_failed. - Confirm the organization moves
ACTIVE -> GRACE; send it again and confirmGRACE -> OVERDUE. - Trigger or simulate
subscription.not_renew. - Confirm the organization remains
ACTIVEandnon_renewing = true. - Trigger or simulate
subscription.disable. - Confirm the organization becomes
CANCELLEDand clearssubscription_code,email_token, grace state, andnon_renewing.
Use Paystack's current test payment details from the official docs when validating cards, failures, and pending payment states: https://paystack.com/docs/payments/test-payments/
Rollout checklist
- Apply database migrations through
036_payment_webhook_events. - Deploy backend and frontend with
PAYMENT_ENABLED=true,PAYMENT_PROVIDER=paystack, and livePAYSTACK_SECRET_KEY. - Keep
PAYMENT_PLAN_SYNC_ENABLED=falseunless intentionally creating missing provider plans. - Confirm
/api/v1/core/plansreturns the expected paid and free plans. - Complete one live low-value paid checkout with an internal organization.
- Confirm the transaction appears in organization billing history with both
ovh_...and Paystack references. - Confirm
/admin/billing/subscriptionsreconciliation has no unexpected issues. - Confirm recent webhook events appear under Recent Paystack webhooks and signatures are valid.
- Confirm expired
core_public.payment_webhook_eventsrows are purged byPaymentWebhookRetentionScheduler.
Post-launch monitoring
After enabling Paystack, review billing health daily during the first launch week:
- Run the admin billing reconciliation check from
/admin/billing/subscriptions. - Investigate any organization where local subscription state conflicts with provider subscription metadata.
- Watch backend logs for rejected webhook signatures, unrecognized webhook events, missing subscription codes, and failed renewal transaction writes.
- Confirm duplicate webhook deliveries remain idempotent and do not create duplicate
core_public.billing_transactionsrows. - Confirm org admins receive billing emails for payment success, renewal success, failed payment, overdue status, cancellation, and plan changes when mail is enabled.
Data retention and exports
core_public.payment_webhook_events.raw_body may contain personal or payment-provider data. Treat this table as restricted operational evidence:
- Do not include
core_public.payment_webhook_eventsin general analytics exports, customer exports, or long-lived support extracts. - If database backups are retained longer than
PAYMENT_WEBHOOK_RETENTION_DAYS, either exclude this table from long-term backup sets or apply the same retention policy to restored/exported copies. - Full raw payload access is restricted to super-admin billing diagnostics. Non-elevated service access must use sanitized payloads.
- The purge job deletes rows after
expires_at; do not bypass it for routine reporting.
Manual repair
Use manual repair only after checking Paystack and the local billing transaction record:
- If a Paystack payment succeeded but the organization did not activate, use Replay payment on
/admin/billing/subscriptionswith theovh_...reference. - Re-run reconciliation and confirm the organization status, plan, provider subscription code, and transaction status now match.
- If the issue came from a webhook payload rather than a known payment reference, inspect Recent Paystack webhooks on
/admin/billing/subscriptionsand compare the stored raw payload with Paystack's dashboard event. - If manual DB repair is unavoidable, preserve financial records. Do not delete
core_public.billing_transactions; append notes to the incident record and re-run reconciliation after repair.
Rollback procedure
- Disable new checkouts by setting
PAYMENT_ENABLED=falseor routing users away from checkout. - Keep the webhook endpoint online while investigating so renewal/failure events are still recorded.
- If rolling back migrations, do not run the
035down migration whilecore_public.billing_transactions.user_id IS NULLrows exist; it intentionally fails for manual review. - Reconcile Paystack dashboard state against
/admin/billing/subscriptionsbefore re-enabling checkout. - Rotate
PAYSTACK_SECRET_KEYif any secret exposure is suspected during rollback.
Expected webhook fields
The backend currently expects these Paystack webhook fields:
Headers:
x-paystack-signature(for webhook verification)
Payload:
eventdata.referencedata.statusdata.amountdata.currencydata.paid_atdata.subscription_code, ordata.subscription.subscription_codedata.email_tokendata.plan.plan_code
The x-paystack-signature header is required and must be validated before processing the webhook payload or trusting any event or data.* value.
If Paystack test-mode payloads differ, update backend/internal/infra/payment/paystack.go and the parser tests before production.