---
name: talo-stack-laravel
description: Laravel kit for Talo Pay bank-transfer payments (Argentina, CVU/alias, ARS) through the Talo Partner Hub, tested on Laravel 13 and 12 with PHP 8.3+. Covers a server-only Http-facade client with typed errors and retries only on network errors and 502, checkout that creates the payment with external_id = order id and shows CVU, alias, amount and expiry with status polling, a raw-body signed webhook ($request->getContent(), hash_hmac + hash_equals) in the stateless api routes outside CSRF, durable idempotency (unique event ids with insertOrIgnore and a fulfil-once conditional update in one DB::transaction, a queued job), reconciliation with an Artisan command on the scheduler, deploy notes for Forge, Laravel Cloud, a VPS and Railway, and the sandbox test. Use when the project uses Laravel (PHP) and the user wants to charge by bank transfer (CVU/alias, ARS) with Talo or Talo Pay.
---

# Talo Pay on Laravel
The Laravel code for the 10-rule contract at https://talopartnerhub.vercel.app/docs/stacks.md (hub: `https://talopartnerhub.vercel.app`, API `/docs/api.md`, webhooks `/docs/webhooks.md`). Keys, the full sandbox plan and going live: core skills `talo-payments` and `talo-go-live`. Everything below ran on Laravel 13.39 (current; PHP 8.3 and 8.5, SQLite and PostgreSQL 17) and 12.70 (PHP 8.3). Laravel 11 runs it too (CSRF syntax below) but is end of life since 2026-03-12.

## Setup
No Composer package: the `Http` facade, `hash_hmac`/`hash_equals`, queues and the scheduler ship with Laravel.
```bash
php artisan install:api   # only if routes/api.php is missing (Laravel 11+); also installs Sanctum
npx talo-hub login        # sandbox; the person authorizes Talo in the browser and it exits when they do
npx talo-hub keys create --name "<project>" --scope charge --dotenv .env   # writes TALO_HUB_API_KEY, never printed
```
`.env` stays gitignored (Laravel's default); `.env.example` gets empty `TALO_HUB_API_KEY=`, `TALO_HUB_WEBHOOK_SECRET=`, `TALO_HUB_DEV_FORWARD=false`. Read them only in `config/services.php`: after `php artisan config:cache` (every production deploy) `env()` returns null anywhere else. Never pass them to Blade, Inertia/Livewire props, `VITE_*` or logs.
```php
    'talo' => [
        'key' => env('TALO_HUB_API_KEY'),                   // tph_test_… (sandbox) / tph_live_… (production)
        'webhook_secret' => env('TALO_HUB_WEBHOOK_SECRET'), // whsec_… of THIS environment's endpoint
        'accept_forwarded' => env('TALO_HUB_DEV_FORWARD', false), // local dev only
    ],
```

## Hub client (server only)
`app/Talo/TaloHubException.php`:
```php
namespace App\Talo;

class TaloHubException extends \RuntimeException
{
    // $status: HTTP status, 0 = network error. $errorCode: error.code (invalid_amount, talo_unavailable…).
    public function __construct(public readonly int $status, public readonly string $errorCode, string $message,
        public readonly array $details = [], ?\Throwable $previous = null)
    {
        parent::__construct("Talo Partner Hub {$status} {$errorCode}: {$message}", 0, $previous);
    }
}
```
`app/Talo/TaloHub.php`:
```php
namespace App\Talo;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

class TaloHub // https://talopartnerhub.vercel.app/docs/api.md
{
    /** The same external_id returns the same payment, so retrying never charges twice. */
    public function createPayment(array $body): array
    {
        return $this->send(fn () => $this->http(tries: 3, timeout: 25)->post('/payments', $body)); // Talo can take 15-20 s
    }

    /** One attempt: a webhook must answer within 10 s, and the hub retries the delivery. */
    public function getPayment(string $id): array
    {
        return $this->send(fn () => $this->http(tries: 1)->get('/payments/'.rawurlencode($id)));
    }

    private function http(int $tries, int $timeout = 8): PendingRequest
    {
        $key = (string) config('services.talo.key');
        throw_unless(str_starts_with($key, 'tph_test_') || str_starts_with($key, 'tph_live_'),
            new TaloHubException(0, 'not_configured', 'TALO_HUB_API_KEY is missing or not a tph_ key'));

        return Http::baseUrl('https://talopartnerhub.vercel.app/api/v1')->withToken($key)->acceptJson()
            ->connectTimeout(3)->timeout($timeout)
            // Retry only network errors and 502 talo_unavailable; a 4xx is final.
            ->retry($tries, fn (int $attempt) => 400 * $attempt, fn (\Throwable $e) => $e instanceof ConnectionException
                || ($e instanceof RequestException && $e->response->status() === 502), throw: false);
    }

    private function send(callable $request): array
    {
        try {
            $response = $request();
        } catch (ConnectionException $e) {
            throw new TaloHubException(0, 'network_error', $e->getMessage(), previous: $e);
        }
        if ($response->failed()) { // { "error": { "code", "message", "details"? } }
            throw new TaloHubException($response->status(), (string) $response->json('error.code', 'unknown'),
                (string) $response->json('error.message', $response->reason()), (array) $response->json('error.details', []));
        }

        return $response->json();
    }
}
```
By `errorCode`: `unauthorized` (401), `insufficient_scope`/`sandbox_only` (403) mean a wrong key, never retried; `talo_reconnect_required` (409): the account owner runs `npx talo-hub login` again (`--production` for a live key); `invalid_amount`/`invalid_field` (422) and `talo_rejected` (`details['talo_message']`) are your request; `payment_not_found` (404) on a read usually means a key from the other environment. Only `network_error` (0) and `talo_unavailable` (502) retry, with the same external_id.

## Checkout
`routes/web.php` (normal web routes; the buyer's form keeps its CSRF token):
```php
Route::post('/orders/{order}/pay', [TaloCheckoutController::class, 'store'])->name('orders.pay');
Route::get('/orders/{order}/payment', fn (Order $order) => view('orders.payment', ['order' => $order]))->name('orders.payment');
Route::get('/orders/{order}/payment-status', [TaloCheckoutController::class, 'status'])->name('orders.payment.status');
```
`app/Http/Controllers/TaloCheckoutController.php`: `external_id` is `order-<id>`, then `order-<id>-2`… only after EXPIRED; the amount comes from the order row, minus what an expired payment already received (that money arrived: never ask for the total twice).
```php
namespace App\Http\Controllers;

use App\Models\Order;
use App\Talo\PaymentSync;
use App\Talo\TaloHub;
use App\Talo\TaloHubException;
use Illuminate\Support\Facades\DB;

class TaloCheckoutController // authorize all three like your other order pages (policy, signed URL…)
{
    public function store(Order $order, TaloHub $hub)
    {
        // Under a row lock: a double click reuses the external_id; after EXPIRED a new one is used.
        [$externalId, $amount] = DB::transaction(function () use ($order) {
            $o = Order::whereKey($order->getKey())->lockForUpdate()->firstOrFail();
            if ($o->payment_external_id === null || $o->payment_status === 'EXPIRED') {
                $attempt = $o->payment_attempt + 1;
                // Money sent to the expired payment did arrive: the new one asks only for the rest.
                $amount = ($o->payment_amount ?? $o->total) - ($o->payment_status === 'EXPIRED' ? $o->paid_amount : 0);
                $o->forceFill(['payment_attempt' => $attempt, 'payment_id' => null, 'payment_status' => null, 'paid_amount' => 0,
                    'payment_amount' => $amount, 'payment_external_id' => "order-{$o->id}".($attempt > 1 ? "-{$attempt}" : '')])->save();
            }
            return [$o->payment_external_id, round((float) ($o->payment_amount ?? $o->total), 2)]; // never from the request
        });

        try {
            $payment = $hub->createPayment(['external_id' => $externalId, 'amount' => $amount, 'motive' => "Pedido #{$order->id}"]);
        } catch (TaloHubException $e) {
            report($e);
            return back()->withErrors(['payment' => 'No pudimos generar los datos de pago. Probá de nuevo en un momento.']);
        }
        DB::transaction(fn () => PaymentSync::apply($payment)); // CVU, alias, expiry, status; a retry may return it paid

        return redirect()->route('orders.payment', $order);
    }

    public function status(Order $order, TaloHub $hub) // for the buyer's screen only: never fulfils
    {
        if ($order->payment_id !== null && in_array($order->payment_status, ['PENDING', 'UNDERPAID'], true)) {
            rescue(fn () => $hub->getPayment($order->payment_id), report: false); // brings the hub's events, so our webhook, up to date
        }
        // The STORED status: the page renders it, so answering the hub's would reload it in a loop until the webhook lands.
        return response()->json(['status' => $order->fresh()->payment_status]);
    }
}
```
Cast `total`/`payment_amount`/`paid_amount` to `decimal:2` and `payment_expires_at`/`fulfilled_at` to `datetime` in the Order model. `resources/views/orders/payment.blade.php` shows CVU, alias, exact amount and expiry, asks for the rest when UNDERPAID, and polls only to refresh the screen (3 s backing off to 10 s, paused while the tab is hidden, stops on the final page):
```blade
@php($ars = fn ($n) => '$ '.number_format((float) $n, 2, ',', '.'))
<h1>Pedido #{{ $order->id }}</h1>
@if (in_array($order->payment_status, ['SUCCESS', 'OVERPAID'], true))
    <p>¡Pago recibido! Estamos preparando tu pedido.</p>
@elseif (in_array($order->payment_status, [null, 'EXPIRED'], true))
    <p>@error('payment') {{ $message }} @else Los datos de pago vencieron o no se generaron. @enderror</p>
    <form method="POST" action="{{ route('orders.pay', $order) }}">@csrf <button>Generar datos de pago</button></form>
@else
    <p>Transferí exactamente <strong>{{ $ars($order->payment_amount) }}</strong> al CVU <code>{{ $order->payment_cvu }}</code> (alias
        <code>{{ $order->payment_alias }}</code>) antes del {{ $order->payment_expires_at->timezone('America/Argentina/Buenos_Aires')->format('d/m/Y H:i') }}.</p>
    @if ($order->payment_status === 'UNDERPAID')
        <p>Recibimos {{ $ars($order->paid_amount) }}: transferí los {{ $ars($order->payment_amount - $order->paid_amount) }} que faltan.</p>
    @endif
    <script>
        (() => {
            const url = @json(route('orders.payment.status', $order)), shown = @json($order->payment_status);
            let delay = 3000; // 3 s, backing off to 10 s; the server renders each new state
            const poll = async () => {
                try {
                    const res = document.hidden ? null : await fetch(url, { headers: { Accept: 'application/json' } });
                    if (res?.ok && (await res.json()).status !== shown) return location.reload();
                } catch {}
                setTimeout(poll, (delay = Math.min(delay * 1.5, 10000)));
            };
            setTimeout(poll, delay);
        })();
    </script>
@endif
```

## Webhook endpoint
`Route::post('/webhooks/talo', TaloWebhookController::class);` in `routes/api.php` → `POST /api/webhooks/talo`, in the stateless `api` group (no session, cookies or CSRF). Register it per environment: `npx talo-hub webhooks add https://<host>/api/webhooks/talo` (checks the URL, prints the `whsec_…` once; `--dotenv <file>` writes it as `TALO_HUB_WEBHOOK_SECRET`). What breaks it in Laravel:
- Verifying `json_encode($request->all())` or `$request->json()`: re-encoding changes the bytes (`\/`, unicode escapes, `15000.0`). Verify `$request->getContent()` (TrimStrings/ConvertEmptyStringsToNull only touch input).
- A route in `routes/web.php` gets CSRF (419) and a session. If it must live there, exclude it in `bootstrap/app.php` `withMiddleware()`: Laravel 13 `$middleware->preventRequestForgery(except: ['webhooks/talo']);`, 11/12 `$middleware->validateCsrfTokens(except: ['webhooks/talo']);` (a deprecated alias in 13). Tested: 200 excluded, 419 not. The URL check of `webhooks add` refuses a 419 (verdict `csrf`), and `webhooks test` shows it too.
- `auth`, `auth:sanctum`, `verified`, `signed`, `throttle` (`$middleware->throttleApi()`) or any redirect (login, http→https, www) on this path. Apache's default `public/.htaccess` 301-redirects a trailing slash: register the URL exactly as routed.

`app/Talo/WebhookSignature.php` (constant time, hex check, 300 s window):
```php
namespace App\Talo;

// talo-hub-signature: t=<unix seconds>,v1=<64 lowercase hex>; v1 = HMAC-SHA256(secret, "<t>.<raw body>")
final class WebhookSignature
{
    public static function verify(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
    {
        $parts = [];
        foreach (explode(',', (string) $header) as $pair) {
            [$key, $value] = array_pad(explode('=', trim($pair), 2), 2, null);
            if ($value === null || isset($parts[$key])) {
                return false; // malformed, or t / v1 repeated
            }
            $parts[$key] = $value;
        }
        [$t, $v1] = [$parts['t'] ?? '', $parts['v1'] ?? ''];

        if (! ctype_digit($t) || abs(($now ?? time()) - (int) $t) > 300 || preg_match('/^[0-9a-f]{64}\z/', $v1) !== 1) {
            return false;
        }

        return hash_equals(hash_hmac('sha256', "{$t}.{$rawBody}", $secret), $v1); // constant time
    }
}
```
`app/Http/Controllers/TaloWebhookController.php`: verify → parse → 2xx for non-payment events and known event ids → GET the payment (503 if that fails, so the hub retries) → one transaction: insert the event id + apply → 200. The worst case is one 8 s hub read, inside the 10 s budget.
```php
namespace App\Http\Controllers;

use App\Talo\PaymentSync;
use App\Talo\TaloHub;
use App\Talo\TaloHubException;
use App\Talo\WebhookSignature;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class TaloWebhookController
{
    public function __invoke(Request $request, TaloHub $hub)
    {
        $raw = $request->getContent(); // the exact signed bytes; never re-encode $request->all()
        // `talo-hub events listen --forward-to` relays events unsigned, with talo-hub-forwarded: 1.
        $forwardedInDev = config('services.talo.accept_forwarded') === true && app()->isLocal()
            && $request->header('talo-hub-forwarded') === '1';
        if (! $forwardedInDev) {
            $secret = (string) config('services.talo.webhook_secret');
            throw_if($secret === '', new \RuntimeException('TALO_HUB_WEBHOOK_SECRET is not set')); // 500, logged, retried
            if (! WebhookSignature::verify($raw, $request->header('talo-hub-signature'), $secret)) {
                return response()->json(['error' => 'invalid signature'], 400);
            }
        }

        $event = json_decode($raw, true);
        if (! is_array($event) || ! is_int($event['id'] ?? null) || ! is_string($event['type'] ?? null)) {
            return response()->json(['error' => 'invalid event'], 400);
        }
        $paymentId = $event['data']['payment']['id'] ?? null;
        if (! str_starts_with($event['type'], 'payment.') || ! is_string($paymentId)) {
            return response()->json(['received' => true]); // webhook.test, account.updated
        }
        if (DB::table('talo_webhook_events')->where('id', $event['id'])->exists()) {
            return response()->json(['received' => true, 'duplicate' => true]);
        }

        try {
            $payment = $hub->getPayment($paymentId); // act on the current status, not the snapshot
        } catch (TaloHubException $e) {
            report($e);
            return response()->json(['error' => 'could not read the payment'], 503); // the hub retries
        }
        DB::transaction(function () use ($event, $payment) {
            $new = DB::table('talo_webhook_events')->insertOrIgnore(['id' => $event['id'],
                'type' => $event['type'], 'payment_id' => $payment['id'], 'created_at' => now()]);
            if ($new === 1) { // 0: a parallel delivery of this event got here first
                PaymentSync::apply($payment);
            }
        });

        return response()->json(['received' => true]);
    }
}
```
`app/Talo/PaymentSync.php`, shared with checkout and reconciliation: status only moves forward; the conditional update fulfils once. It also stores CVU, alias and expiry, so an order whose create answer was lost still gets them from the webhook.
```php
namespace App\Talo;

use App\Jobs\FulfilOrder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

final class PaymentSync
{
    // Statuses only move forward: an older read never overwrites a newer status.
    private const RANK = ['PENDING' => 0, 'UNDERPAID' => 1, 'SUCCESS' => 2, 'EXPIRED' => 2, 'OVERPAID' => 3];

    /** Applies a payment READ FROM THE HUB to its order. Call it inside DB::transaction(). */
    public static function apply(array $payment): void
    {
        $rank = self::RANK[$payment['status']] ?? null;
        if ($rank === null) {
            return;
        }
        DB::table('orders')
            ->where('payment_external_id', $payment['external_id'])
            ->where(fn ($q) => $q->whereNull('payment_status')
                ->orWhereIn('payment_status', array_keys(array_filter(self::RANK, fn ($r) => $r <= $rank))))
            ->update(['payment_id' => $payment['id'], 'payment_status' => $payment['status'], 'payment_amount' => $payment['amount'],
                'paid_amount' => $payment['paid'] ?? 0, 'payment_cvu' => $payment['cvu'], 'payment_alias' => $payment['alias'],
                // in the app's timezone, which is how a timestamp column is read back
                'payment_expires_at' => Carbon::parse($payment['expires_at'])->setTimezone(config('app.timezone')), 'updated_at' => now()]);

        if (! in_array($payment['status'], ['SUCCESS', 'OVERPAID'], true)) {
            return; // UNDERPAID is NOT paid: never fulfil it
        }
        // Fulfil once: only the update that flips fulfilled_at from NULL affects a row.
        $won = DB::table('orders')->where('payment_id', $payment['id'])->whereNull('fulfilled_at')
            ->update(['fulfilled_at' => now()]);
        if ($won === 1) {
            // database queue: the job row commits with fulfilled_at. Redis/SQS: see below.
            FulfilOrder::dispatch(DB::table('orders')->where('payment_id', $payment['id'])->value('id'));
        }
    }
}
```
`app/Jobs/FulfilOrder.php`: `php artisan make:job FulfilOrder` (`ShouldQueue`, constructor `public int $orderId`); emails, invoices and stock go in `handle()`, never in the request. With `QUEUE_CONNECTION=database` (the Laravel 11+ default) the job row commits atomically with `fulfilled_at` (tested: a rollback leaves neither); run `php artisan queue:work`. On Redis/SQS the push is outside the transaction: `->afterCommit()` avoids a job for a rolled-back fulfilment, but a crash right after the commit loses it, so keep this job on the database queue (`->onConnection('database')` plus a `queue:work database` worker). `dispatch(...)->afterResponse()` needs no worker but dies with the PHP process: only for work you can lose.

## Durable storage
`php artisan make:migration add_talo_payments`, `up()` (your `orders` table already holds the total as `decimal(12, 2)`, never float; `down()` drops the indexes, columns and table):
```php
        Schema::create('talo_webhook_events', function (Blueprint $table) {
            $table->unsignedBigInteger('id')->primary(); // the hub's event id: the unique key that makes it once
            $table->string('type', 40);
            $table->string('payment_id', 150)->nullable();
            $table->timestamp('created_at')->nullable();
        });
        Schema::table('orders', function (Blueprint $table) {
            $table->unsignedSmallInteger('payment_attempt')->default(0);
            $table->string('payment_external_id', 100)->nullable()->unique(); // order-1001, order-1001-2…
            $table->string('payment_id', 150)->nullable()->unique();          // PAR-talo-hub-…
            $table->string('payment_status', 12)->nullable()->index();
            $table->decimal('payment_amount', 12, 2)->nullable();           // what this payment asks for
            $table->decimal('paid_amount', 12, 2)->default(0);
            $table->string('payment_cvu', 22)->nullable();
            $table->string('payment_alias', 64)->nullable();
            $table->timestamp('payment_expires_at')->nullable();
            $table->timestamp('fulfilled_at')->nullable();
        });
```
Not in memory, the session, `static` properties or the `array`/`file` cache: PHP-FPM workers, servers and deploys don't share them, so a duplicate would fulfil twice. The primary key on `talo_webhook_events.id` and `UPDATE … WHERE fulfilled_at IS NULL` hold under parallel deliveries (tested on PostgreSQL: `payment.updated`, `payment.paid`, a duplicate of it and a reconcile run at once gave one fulfilment and one job; the duplicate's `insertOrIgnore` waits for the first commit and returns 0). `insertOrIgnore` is `ON CONFLICT DO NOTHING` (PostgreSQL), `INSERT OR IGNORE` (SQLite), `INSERT IGNORE` (MySQL/MariaDB, where it also hides other errors).

## Reconciliation
`routes/console.php` (imports `PaymentSync`, `TaloHub`, `TaloHubException`, `DB`, `Schedule`): every 5 minutes, orders still PENDING or UNDERPAID whose payment moved in the last 3 days (`updated_at`, so a new payment on an old order counts) are re-read and applied.
```php
Artisan::command('talo:reconcile {--days=3}', function (TaloHub $hub) {
    $orders = DB::table('orders')->select('id', 'payment_id')->whereNotNull('payment_id')
        ->whereIn('payment_status', ['PENDING', 'UNDERPAID'])
        ->where('updated_at', '>=', now()->subDays((int) $this->option('days')));
    foreach ($orders->lazyById(100) as $order) { // by id: safe while updated rows leave the filter
        try {
            $payment = $hub->getPayment($order->payment_id);
        } catch (TaloHubException $e) {
            report($e);
            continue; // the next run tries again
        }
        DB::transaction(fn () => PaymentSync::apply($payment));
    }
})->purpose('Re-read Talo payments still PENDING or UNDERPAID, in case a webhook never arrived');
Schedule::command('talo:reconcile')->everyFiveMinutes()->withoutOverlapping(10)->onOneServer();
```
Check it with `php artisan schedule:list`. The host runs `* * * * * cd /path-to-app && php artisan schedule:run >> /dev/null 2>&1` (or `php artisan schedule:work` as a process). `withoutOverlapping(10)`: a run killed mid-way (deploy, OOM) frees its lock after 10 minutes, not the default 24 hours. It and `onOneServer()` need a shared lock store (`CACHE_STORE=database`, the default, or redis).

## Local development
Run `php artisan serve` (http://localhost:8000), `php artisan queue:work` (runs `FulfilOrder`) and `npx talo-hub events listen --forward-to http://localhost:8000/api/webhooks/talo`. Forwarded events are unsigned (`talo-hub-forwarded: 1`): accepted only with `APP_ENV=local` and `TALO_HUB_DEV_FORWARD=true`, both in `.env` (`php artisan serve` doesn't pass most shell variables to the app). Sail: `http://localhost/api/webhooks/talo`; Herd/Valet: `http://<app>.test/api/webhooks/talo` (if that redirects to https, forward to `php artisan serve` instead: a 301/302 turns the POST into a GET). Signed deliveries need a public https URL (a deployment or a tunnel).

## Deploy
- URL `https://<host>/api/webhooks/talo`, final (no redirect, no trailing slash), with no basic auth, login, Cloudflare challenge or WAF rule in front.
- Dev/staging: a `tph_test_` key and their own endpoint + secret. Production only: the `tph_live_` key (`npx talo-hub login --production`, `keys create … --production`) and its own endpoint (`webhooks add <url> --production`, then `webhooks test <id> --production`) + secret; `APP_ENV=production`, `APP_DEBUG=false`, no `TALO_HUB_DEV_FORWARD`. Deploys run `php artisan migrate --force` and `php artisan config:cache`.
- Forge: variables in the site's environment; toggle **Laravel Scheduler** (adds the every-minute `schedule:run`); a queue worker for `php artisan queue:work`. A plain VPS: the cron line above plus Supervisor or systemd for `queue:work`.
- Laravel Cloud: variables per environment; **Scheduler** toggle on the App compute cluster; a managed queue or worker cluster; with replicas, `onOneServer()` keeps one reconcile run; no hibernation in production (it pauses the scheduler).
- Railway: variables per environment; `php artisan schedule:work` and `php artisan queue:work` as two more long-running services of the same repo (Railway cron services can skip runs).

## Test it
`tests/Unit/WebhookSignatureTest.php` (PHPUnit, `php artisan test`):
```php
namespace Tests\Unit;

use App\Talo\WebhookSignature;
use PHPUnit\Framework\TestCase;

class WebhookSignatureTest extends TestCase // vector: https://talopartnerhub.vercel.app/docs/webhooks.md
{
    private const BODY = '{"id":1,"type":"webhook.test","created_at":"2026-10-26T00:00:00.000Z","data":{"message":"Talo Partner Hub test event","environment":"sandbox"}}';
    private const V1 = 'ee8e039a9e5e9ce3eac17705f1133bfd58776d46f5163e01ad6a42d2cc540b9e';

    public function test_vector_tampering_time_window_and_malformed_headers(): void
    {
        $ok = fn (?string $header, string $body = self::BODY, int $now = 1793000000) // $now = t: no 5-minute check
            => WebhookSignature::verify($body, $header, 'whsec_test_secret', $now);
        $sig = 't=1793000000,v1='.self::V1;
        $this->assertTrue($ok($sig));
        $this->assertFalse($ok($sig, str_replace('sandbox', 'production', self::BODY))); // tampered body
        $this->assertTrue($ok($sig, now: 1793000300));
        $this->assertFalse($ok($sig, now: 1793000301)); // stale t
        $this->assertFalse($ok($sig, now: 1792999699)); // t in the future
        foreach ([null, '', 'v1='.self::V1, 't=1793000000', substr($sig, 0, -1), substr($sig, 0, -1).'z',
            't=1793000000,v1='.strtoupper(self::V1), $sig.',v1='.self::V1] as $header) {
            $this->assertFalse($ok($header), (string) $header); // missing, short, non-hex, uppercase, repeated
        }
    }
}
```
Feature tests (`RefreshDatabase`, `Http::fake()` for the hub, `Queue::fake()`) sign `$raw` with `time()` and post it byte for byte with `$this->call('POST', '/api/webhooks/talo', [], [], [], $this->transformHeadersToServerVars($headers), $raw)`. Assert: hub says SUCCESS → 200, `Queue::assertPushed(FulfilOrder::class, 1)`; same event id again → 200, still one; zeroed `v1` or no header → 400, nothing written; hub says UNDERPAID → not fulfilled; hub 502 → 503 and no event row, then the retry fulfils; the status endpoint answers the stored status even when the hub already says SUCCESS; after an UNDERPAID payment EXPIRED, the next one asks only for the rest.

Sandbox (deployed, or forwarding locally):
1. Check out an order, `npx talo-hub sandbox pay <payment_id>` → SUCCESS in ~10–25 s, fulfilled once.
2. New order, `--amount` below the price → UNDERPAID, the page asks for the rest, not fulfilled; pay the rest → fulfilled. Another with `--amount` above → OVERPAID → fulfilled.
3. The same event twice: `events listen --forward-to …` in two terminals delivers every event twice → two 200s, one fulfilment (`npx talo-hub events list --payment <id>`).
4. `npx talo-hub webhooks test <id>` → delivered; `npx talo-hub doctor` → ready.

## Checklist
- [ ] `TALO_HUB_*` only in `.env`/host variables, read via `config('services.talo.*')`
- [ ] Amount from the order row (minus what an expired payment received); `external_id` `order-<id>`, a new suffix only after EXPIRED
- [ ] Webhook in `routes/api.php` (or CSRF-excluded), no auth or redirect, URL registered exactly
- [ ] Verifier on `$request->getContent()`, passes the test vector, 400 on failure
- [ ] Unsigned forwarded events only with `APP_ENV=local` + `TALO_HUB_DEV_FORWARD=true`
- [ ] Payment re-read before acting (503 if that fails); event id + status + fulfil-once update in one `DB::transaction`
- [ ] UNDERPAID never fulfilled; SUCCESS/OVERPAID once; slow work in `FulfilOrder` with a worker
- [ ] Status endpoint answers the stored status (no reload loop) and never fulfils
- [ ] `talo:reconcile` scheduled and the scheduler running on the host
- [ ] Sandbox plan passed, `webhooks test` delivered, `doctor` ready; `tph_live_` only in production
