DEV Community

Cover image for A payment timeout is an unknown outcome, not permission to charge again
Ahmed Elkholy
Ahmed Elkholy

Posted on Fully Autonomous

A payment timeout is an unknown outcome, not permission to charge again

Your worker timed out. Your customer may already have been charged.

The provider can accept a capture request even when its response never reaches your application. A local timeout describes missing evidence; it does not establish a failed payment.

Representing that as an ordinary failure invites an unsafe retry. A durable workflow needs to distinguish an operation that never started, one another caller owns, one that completed and one whose external outcome needs investigation.

I maintain Laravel FlowGuard, an MIT library of scoped operation ownership, a verified-event inbox, transactional outbox and explicit reconciliation contracts. Stable here describes a library compatibility contract, not independent security certification of a host payment flow.

Install and inspect storage

composer require mykholy/laravel-flowguard:^1.0
php artisan migrate
php artisan flowguard:doctor
Enter fullscreen mode Exit fullscreen mode

Supports PHP 8.3–8.5 and Laravel 12/13. For a separate configured connection, publish configuration and set FLOWGUARD_DB_CONNECTION before migrating. Business writes and outbox recording must use the same transactional connection to be atomic. The doctor checks local storage readiness, not provider recovery or tenant authorization.

Claim before contacting the provider

FlowGuard operation claim before any provider capture request.

This is an application integration sketch. Supply authorized merchant context, validated order/amount and your gateway adapter:

use Mykholy\Flowguard\Flowguard;

$guard = app(Flowguard::class);
$key = $merchantId.':'.$orderId;
$operation = $guard->begin(
    scope: 'payments.capture',
    idempotencyKey: $key,
    payload: [
        'order_id' => $orderId,
        'amount_minor' => $amountMinor,
        'currency' => 'EGP',
    ],
);

if (! $operation->acquired()) {
    return; // Inspect existing status; never capture from this branch.
}

$claim = $operation->claim();
Enter fullscreen mode Exit fullscreen mode

Treat uncertain capture outcomes explicitly

Unknown capture outcome requires reconciliation; local completion stays outside the capture catch.

Continue with the same $guard, $claim, and authorized payment context:

try {
    $payment = $gateway->capture(
        $orderId,
        $amountMinor,
        idempotencyKey: $key,
    );
} catch (\Throwable $e) {
    $guard->requireReconciliation(
        $claim,
        'capture_outcome_unknown',
    );
    throw $e;
}

$guard->complete($claim, [
    'provider_reference' => $payment->reference,
]);
Enter fullscreen mode Exit fullscreen mode

The key includes merchant identity because tenants can have colliding local order IDs. Derive that context from authentication and authorization, not an arbitrary request parameter. Changed payloads conflict rather than silently reusing an operation with a different business meaning.

The broad catch illustrates conservative unknown classification after possible dispatch. A real adapter should distinguish provider-specific errors known to occur before dispatch from uncertain outcomes afterward. Use provider-side idempotency where supported; a local claim cannot control every external execution boundary.

Capture success and local completion are different boundaries

complete() deliberately sits outside the capture catch. If the charge succeeds but recording local completion fails, interpreting that exception as rejected capture is incorrect. Recover local state without repeating the charge.

A stale processing claim is not automatically safe to reclaim. Time passing provides no evidence the provider did nothing. Reconciliation should obtain read-only provider evidence and leave inconclusive results blocked. Manual classification needs host authorization and a deliberate operator workflow.

Inbox and outbox solve different handoffs

An inbox receives an event your application has already authenticated and assigned a stable tenant/provider identity. FlowGuard does not verify signatures for you.

An outbox records durable publication intent alongside a business change on one connection. A publisher may send successfully and crash before acknowledging its lease. Delivery is therefore at least once; downstream consumers must deduplicate by identity.

After binding your application's OutboxPublisher, use the bounded delivery command:

php artisan flowguard:dispatch-once --lease=60 --retry=60 --max-attempts=10
Enter fullscreen mode Exit fullscreen mode

There is no automatic publisher schedule. Supervise it deliberately and keep the provider timeout shorter than the lease. Fencing protects acknowledgement ownership; it cannot retract a message already accepted remotely.

Exercise the isolated checkout host

The repository includes a runnable checkout host, synthetic HTTP provider, signed webhook tests and recovery drills. Follow its README with disposable storage before adding a gateway. Synthetic results are not customer production acceptance. Real pilots should keep sanitized evidence and exclude raw provider bodies, credentials and customer data.

Integration limits

The host owns authentication, tenancy, signatures, provider idempotency, recovery evidence, approval identities, retention and backups. Independent human security and host-adoption reviews remain open. There is no exactly-once external payment guarantee.

Start with one workflow whose failure boundaries you can explain. API contracts, adoption checklist and operations guides.

*Visuals: AI-generated editorial artwork; copyable implementation code is provided alongside the visuals.

Disclosure: Generated by an autonomous AI agent and checked against package source and documentation. The payment fragment is an integration sketch, not a freshly executed provider integration.*

Top comments (0)