GoCardless Direct Debit — Explanation
Module: Payment Gateway Integration
Document Type: Explanation (Understanding-Oriented)
Audience: Backend developers and tech leads new to this integration
Why GoCardless? Design Rationale
GoCardless was added to Waste Vantage as an alternative to Stripe for customers who prefer Direct Debit over card payments. Direct Debit is particularly common in Australian B2B waste management contracts, where recurring invoices and high-value payments make it a natural fit. Unlike card payments, Direct Debit mandates give the business a pre-authorised right to collect funds directly from a customer's bank account — reducing failed payments and eliminating card expiry issues.
The Billing Request Flow — Conceptual Explanation
The GoCardless integration uses a concept called the Billing Request Flow to collect both a mandate (authority to debit) and an immediate payment in a single customer session using the Instant Bank Pay feature alongside the mandate creation. This bypasses typical validation issues and simplifies the backend integration significantly.
The flow works like this:
Why is the redirect asynchronous? Because bank-to-bank transfers are not instant. GoCardless sends webhooks over the following 1–3 business days as the payment moves through the banking system. This is fundamentally different from card payments where authorisation is real-time.
Two Payment Resolution Methods
Because GoCardless events are asynchronous, Waste Vantage implements two complementary methods to detect when a payment has been confirmed:
Method 1: Webhook (Preferred) (NOT IMPLEMENTED YET)
GoCardless sends a POST request to our /payment/gocardless/webhook endpoint whenever the payment status changes. This is the most efficient method — no polling required, events arrive promptly.
When to use webhooks: Any client where the server has a public URL reachable by GoCardless (standard production deployments).
Method 2: API Polling (Fallback)
A Laravel scheduled command (gocardless:poll) runs every 5 minutes. It queries the GoCardless API directly to check whether pending payments have changed status. This method works without any public URL.
When to use polling:
- Clients on private networks or behind firewalls (GoCardless cannot reach the webhook endpoint)
- Development environments (localhost)
- As a safety net alongside webhooks to catch any missed deliveries
The two methods are not mutually exclusive. Running both simultaneously is safe because the ProcessGoCardlessPayment job is idempotent — re-processing an already-paid record has no effect.
All three paths converge on the same ProcessGoCardlessPayment job. This is deliberate — business logic lives in one place only.
The Two Poller Schedules
Polling is registered in app/Console/Kernel.php as two separate runs of the same gocardless:poll command, each tuned for a different failure mode:
| Schedule | Command | Window | Limit | Purpose |
|---|---|---|---|---|
| Every 5 minutes | gocardless:poll | 72 hours | 50 | Real-time safety net for missed webhooks |
| Daily at 06:00 | gocardless:poll --age=168 --limit=100 | 168 hours (7 days) | 100 | Long-tail catch-up for the 7-day reconciliation window |
Why two schedules, not one?
The 5-minute run is tuned for latency: catch a status change as soon as possible after the webhook drops it. Its narrow --age=72 window keeps the query small enough to complete inside the 5-minute cadence even on a busy day.
The 06:00 daily run is tuned for coverage: a payment that missed every webhook for a week — for example, a GoCardless retry cycle that re-submitted a payment days later, or a payment that briefly fell out of the 72h window before GC caught up — will not be re-checked by the 5-minute poller. The nightly run uses a 7-day lookback and a higher limit specifically to find these stragglers and reconcile them.
It is normal to see both runs in storage/logs/gocardless-poll.log on the same day. The 5-minute run will usually be empty (the webhook already handled it); the 06:00 run is where reconciliation work actually shows up.
Both runs write to the same log file and both use withoutOverlapping() to prevent a slow API call from triggering a concurrent run.
How Idempotency Is Achieved
A key concern with dual-mode operation is double processing — what happens if both the webhook and the poller try to update the same payment at the same time?
Several layers prevent this:
- DB Terminal State Check — The poller's query excludes payments with
transaction_status IN ('Paid', 'Failed', 'Cancelled'). A payment already markedPaidis never fetched. - Already-Applied Guard — Inside
checkGoCardlessPayment(), before building an event, the command checks whether the action would be a no-op (e.g., dispatchingconfirmedwhen status is alreadyConfirmed). - DB Transaction — Every job handler wraps updates in
DB::transaction(). Concurrent executions of the same job will queue at the database level; the second will see the already-updated record and perform no meaningful change.
The Event Shape Convention
Both webhooks and polling produce the same event array shape before dispatching ProcessGoCardlessPayment. This is the core design decision that keeps the job clean:
// Real webhook event (decoded from GoCardless JSON payload)
[
'id' => 'EV123REAL',
'resource_type' => 'payments',
'action' => 'paid_out',
'links' => ['payment' => 'PM001'],
'source' => 'webhook',
]
// Synthetic polling event (constructed by PollGoCardlessPayments)
[
'id' => 'POLL-abc123', // synthetic, prefixed 'POLL-'
'resource_type' => 'payments',
'action' => 'paid_out',
'links' => ['payment' => 'PM001'],
'source' => 'api_polling', // marks origin for log tracing
]
The job does not care about source — it only reads resource_type, action, and links. The source field exists purely for log tracing when debugging.
GoCardless Payment Status Lifecycle
GoCardless payments move through a series of internal statuses. Only a subset of these trigger actions in Waste Vantage:
| GoCardless Status | Meaning | Our Action |
|---|---|---|
pending_customer_approval | Customer hasn't completed the hosted page | No action |
pending_submission | Mandate authorised; payment queued for bank submission | No action |
submitted | Payment submitted to bank | No action |
confirmed | Bank confirmed funds will be transferred | Set Pending |
paid_out | Funds received in our GoCardless account | Set succeeded + mark invoice |
failed | Payment failed (insufficient funds, etc.) | Set Failed |
cancelled | Payment was cancelled | Set Cancelled |
Why do we wait for
paid_outrather thanconfirmed?
confirmedmeans the bank has agreed to transfer the funds, but the money has not yet arrived in the GoCardless payout.paid_outis the final confirmation that funds are in the GoCardless holding account and will be included in the next payout to Waste Vantage's bank. Marking the invoicepaidatconfirmedwould be premature.
BECS Direct Debit in Australia (NOT IMPLEMENTED [WE USE PayTo Method])
GoCardless uses the BECS (Bulk Electronic Clearing System) scheme for Australian Direct Debit. This is the standard scheme used by all Australian banks. The GoCardless SDK handles the compliance requirements (BSB validation, account number format, etc.) on the hosted page — Waste Vantage never touches raw bank account details.
Key BECS characteristics:
- Settlement time: 1–3 business days after submission
- Currency: AUD only
- Minimum amount: AUD 1.00
- Maximum amount: No hard limit (GoCardless risk controls apply)
- Failed payment retry: Configurable in GoCardless dashboard (not managed by Waste Vantage)
Security Model
| Concern | Mitigation |
|---|---|
| Webhook authenticity | HMAC-SHA256 using GOCARDLESS_WEBHOOK_SECRET; hash_equals() prevents timing attacks |
| CSRF on webhook | Explicitly exempted in VerifyCsrfToken::$except; security delegated to HMAC |
| Raw bank account data | Never handled by Waste Vantage; GoCardless hosted page takes custody |
| Double charges | Idempotency via terminal-status DB check + DB::transaction() |
| Rate limiting | ApiException caught and logged; Artisan command marks individual records as errored without halting the run |
Relationship to Stripe Integration
The GoCardless integration was deliberately designed to mirror the Stripe integration in structure:
| Aspect | Stripe | GoCardless |
|---|---|---|
| Service class | StripeServices | GoCardlessService |
| Controller | StripePaymentController | GoCardlessController |
| Webhook controller | StripeHookController | GoCardlessWebhookController |
| Success redirect | payment.order.loading-payment | payment.order.loading-payment (same) |
| Payment record type | Stripe | GoCardless |
| Confirmation speed | Real-time | 1–3 business days |
The shared loading page pattern means both payment methods present the same UX to the customer after checkout, regardless of how long confirmation actually takes.