The first time I shipped a direct debit api Laravel integration I made the same mistake everyone makes coming from Stripe cards: I marked the invoice paid on the HTTP response. The create-payment call returned 201, my code flipped the invoice to `paid`, and three days later the bank bounced it for insufficient funds. The fix is to treat direct debit as what it actually is, a mandate-based, asynchronous flow where the create call only returns `pending_submission` and the money is confirmed days later via a webhook. Never trust the response body for settlement state.
Why is direct debit different from card payments?
Card payments are synchronous. You send card details, the issuer authorizes or declines, and the HTTP response tells you the outcome in one round trip. Direct debit (GoCardless, Bacs, SEPA, BECS) does not work like that. It is built on a mandate: the customer authorizes you once to pull money from their bank account on a recurring basis. After that you create payments against the stored mandate, and each payment crawls through the banking network over several business days before it confirms or fails.
Two consequences fall out of this. First, you store a mandate id against the customer once and reuse it for every future charge; you do not collect bank details each time. Second, the create-payment API call is a request, not a result. It returns a payment in `pending_submission`, and the real outcome arrives later. If your billing logic assumes synchronous settlement, it is wrong before you write a line of code.
How do you set up a mandate and charge against it?
The flow has three stages. You authorize a mandate (typically via a hosted redirect flow so you never touch raw bank details), persist the returned mandate id, then create payments against that id whenever you bill. Here is the lifecycle as I model it in the database:
- Authorize a mandate via the provider's hosted flow; on completion you receive a mandate id like MD0001234567890.
- Store that mandate id on the customer (or a dedicated mandates table) along with its status and the scheme: bacs, sepa_core, ach, becs.
- Create a payment against the mandate. The response status is pending_submission, not paid.
- Wait for the payment-confirmed webhook before you treat the money as received.
- Handle the failure paths (failed, late_failure, and charged_back) by reversing whatever you provisionally granted.
The payment moves through `pending_submission` to `submitted` to `confirmed`, and eventually `paid_out` once the provider settles funds to your account. Failures can arrive at `submitted` (the bank rejects the instruction) or even after `confirmed` (a `charged_back` indemnity claim, which on Bacs can land weeks later). Your data model has to allow a payment to leave a terminal-looking state, because in direct debit `confirmed` is not actually final.
How do you create a payment safely with an idempotency key?
Creating money movement is the one place you cannot afford a duplicate. A queue retry, a timeout where the request actually succeeded, or a double-clicked admin button can all fire the same charge twice. Every serious direct debit provider supports an `Idempotency-Key` header: send the same key and the provider returns the original payment instead of creating a second one. I derive the key deterministically from the invoice so a retry of the same logical charge always reuses it.
<?php
namespace App\Services\DirectDebit;
use App\Models\Invoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Throwable;
class PaymentGateway
{
public function chargeInvoice(Invoice $invoice): array
{
// Deterministic key: a retry of THIS invoice reuses it,
// so the provider de-duplicates instead of double-charging.
$idempotencyKey = "invoice-{$invoice->id}-charge";
$response = Http::withToken(config('services.gocardless.token'))
->withHeaders([
'GoCardless-Version' => '2015-07-06',
'Idempotency-Key' => $idempotencyKey,
])
->acceptJson()
// Default throw:true means a 4xx/5xx raises RequestException
// between attempts, so $when can inspect it. Retry only
// transport failures and 5xx; never retry a 4xx.
->retry(3, 200, function (Throwable $e): bool {
if ($e instanceof ConnectionException) {
return true; // transient transport error
}
return $e instanceof RequestException
&& $e->response->serverError();
})
->post(config('services.gocardless.base') . '/payments', [
'payments' => [
'amount' => $invoice->amount_in_pence, // integer minor units
'currency' => 'GBP',
'links' => ['mandate' => $invoice->customer->mandate_id],
'metadata' => ['invoice_id' => (string) $invoice->id],
],
])
->json('payments');
// Persist the provider id + status. Do NOT mark the invoice paid here.
$invoice->payments()->updateOrCreate(
['provider_payment_id' => $response['id']],
['status' => $response['status']], // 'pending_submission'
);
return $response;
}
}A few things are load-bearing here. The amount is an integer in minor units (pence, cents), never a float, or you will eventually charge someone £10.000000000002. The retry callback retries on `ConnectionException` (a dropped connection or timeout) and on a `RequestException` whose response is a 5xx; it deliberately does not retry a 4xx, because a `422` means the mandate is dead or the amount is invalid and retrying just hammers the API. Because `retry()` runs with its default throwing behaviour, a failed response is raised as a `RequestException` between attempts so the callback can see it, and after the attempts are exhausted it re-throws for the caller to handle. The idempotency key makes those retries safe: if the first attempt actually reached the provider before the connection dropped, the retry returns the same payment rather than creating a second one.
The HTTP response tells you the provider accepted your instruction. Only the webhook tells you the bank moved the money. Conflating the two is how you ship a billing system that lies to your accounts team.
Where does the invoice actually get marked paid?
In the webhook handler, and nowhere else. The provider POSTs a signed event when a payment changes state. You verify the signature, look up your local payment by the provider id, and transition the invoice based on the event action. The action you care about for settlement is `confirmed`; the ones that reverse it are `failed`, `late_failure`, and `charged_back`.
<?php
namespace App\Listeners;
use App\Models\Payment;
class UpdatePaymentFromWebhook
{
/**
* Called per-event by the centralized webhook controller AFTER the
* signature has been verified. $event is one item from the payload.
*/
public function handle(array $event): void
{
if ($event['resource_type'] !== 'payments') {
return;
}
$payment = Payment::where('provider_payment_id', $event['links']['payment'])->first();
if (! $payment) {
return; // unknown payment — log and move on, do not 500 the webhook
}
match ($event['action']) {
'confirmed', 'paid_out' => $payment->invoice->markPaid(),
'failed', 'late_failure' => $payment->invoice->markFailed($event['action']),
'charged_back' => $payment->invoice->reverseAndFlag(),
default => null, // submitted, etc. — record status only
};
$payment->update(['status' => $event['action']]);
}
}Notice the handler never throws on an unknown payment; it logs and returns a 200, because a non-2xx response makes the provider retry the whole batch and you end up reprocessing events you already handled. The same webhook delivery can arrive more than once, so every state transition here must be idempotent: calling `markPaid()` twice on an already-paid invoice should be a no-op. I do not register webhook routes per provider; I run one verified entry point, which I walk through in centralizing payment webhooks in a Laravel controller, and lean on the broader patterns in handling payment webhooks with idempotency and retries.
What about reconciliation and recurring charges?
Webhooks get lost. A deploy during a delivery window, a 30-second outage, a misconfigured endpoint: any of these drops events on the floor, and the provider eventually gives up retrying. So I run a nightly reconciliation job that lists payments from the provider updated since the last run and compares each status against my local record. If the provider says `confirmed` and my row still says `submitted`, I missed a webhook, so I replay the transition. This is the safety net, not the primary path; the webhook is primary, reconciliation catches the gaps.
For subscription-style billing, the charge itself runs on a schedule against the stored mandate, which means a queued, scheduled command rather than a synchronous request. I keep payment creation on a queue worker so a slow provider never blocks a web request, and I drive the recurring cadence with the scheduler; the exact setup is in scheduling automatic loan repayments in Laravel.
Direct debit rewards a particular discipline: store the mandate once, charge with an idempotency key, retry only transient errors, and let the webhook (verified, idempotent, backed by reconciliation) be the single source of truth for whether money actually moved. Build it that way and the asynchronous, days-long settlement that bites people coming from cards becomes a non-event. Build it assuming the response means paid, and you will find out the hard way three business days later, exactly when an angry customer's order has already shipped.

