Saltar al contenido
ApiPay Hub · Docs

Receta: Laravel#

Un proyecto completo con Laravel 11/12 y apipay/apipay-php: crea el intent, sirve la vista del widget y recibe los webhooks con el cuerpo crudo, deduplicando por evt_ en base de datos.

mi-tienda/
├── .env
├── bootstrap/app.php                                  CSRF excluido en la ruta del webhook
├── config/services.php                                claves y secreto
├── app/Providers/AppServiceProvider.php               ApiPayClient como singleton
├── app/Http/Controllers/CheckoutController.php
├── app/Http/Controllers/ApiPayWebhookController.php
├── app/Models/{Order,ApipayWebhookEvent}.php
├── database/migrations/…_create_apipay_tables.php
├── resources/views/checkout.blade.php
└── routes/web.php

Instalación#

composer require apipay/apipay-php

# El SDK habla PSR-18/PSR-17 y no trae implementacion propia.
# Laravel ya arrastra Guzzle, pero si tu proyecto no lo tiene:
composer require guzzlehttp/guzzle

.env#

# Backoffice > API keys. La sk_ jamas llega al navegador.
APIPAY_SECRET_KEY=sk_test_EJEMPLO000000000000000000000000
APIPAY_PUBLIC_KEY=pk_test_EJEMPLO000000000000000000000000

# Backoffice > Webhooks. Se muestra UNA sola vez al crear el endpoint.
APIPAY_WEBHOOK_SECRET=whsec_test_EJEMPLO0000000000000000000

config/services.php#

<?php

return [
    // ...el resto de servicios de tu aplicacion

    'apipay' => [
        'secret_key' => env('APIPAY_SECRET_KEY'),
        'public_key' => env('APIPAY_PUBLIC_KEY'),
        'webhook_secret' => env('APIPAY_WEBHOOK_SECRET'),
    ],
];

app/Providers/AppServiceProvider.php#

<?php

namespace App\Providers;

use ApiPay\ApiPayClient;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Singleton: el cliente es inmutable y sin estado mas alla de su configuracion.
        // Una clave pk_ se rechaza aqui mismo, con ConfigurationException.
        $this->app->singleton(ApiPayClient::class, function (): ApiPayClient {
            return new ApiPayClient(
                apiKey: (string) config('services.apipay.secret_key'),
                timeout: 30.0,
                maxRetries: 2,
            );
        });
    }
}

bootstrap/app.php#

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__ . '/../routes/web.php',
        commands: __DIR__ . '/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        // La llama ApiPay, no un navegador con sesion. Sin esto: 419 y cinco reintentos.
        $middleware->validateCsrfTokens(except: ['webhooks/apipay']);
    })
    ->create();

database/migrations/2026_08_07_000000_create_apipay_tables.php#

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table): void {
            $table->id();
            $table->string('reference')->unique();
            // Dinero: entero en unidades menores + divisa ISO 4217. Jamas float ni decimal.
            $table->unsignedBigInteger('amount_minor');
            $table->char('currency', 3);
            $table->string('apipay_intent_id')->nullable()->index();
            $table->string('apipay_client_secret')->nullable();
            // Persistir la clave es lo que hace seguro reintentar el POST del cobro.
            $table->string('apipay_idempotency_key')->nullable();
            $table->string('status')->default('pendiente');
            $table->timestamps();
        });

        Schema::create('apipay_webhook_events', function (Blueprint $table): void {
            $table->id();
            // La UNIQUE es la deduplicacion: la entrega es at-least-once y los
            // reintentos pueden llegar a instancias distintas de la aplicacion.
            $table->string('event_id')->unique();
            $table->string('type');
            $table->timestamp('processed_at');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('apipay_webhook_events');
        Schema::dropIfExists('orders');
    }
};

app/Models/Order.php y app/Models/ApipayWebhookEvent.php#

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    protected $fillable = [
        'reference', 'amount_minor', 'currency',
        'apipay_intent_id', 'apipay_client_secret', 'apipay_idempotency_key', 'status',
    ];

    protected $casts = ['amount_minor' => 'integer'];
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class ApipayWebhookEvent extends Model
{
    public $timestamps = false;

    protected $fillable = ['event_id', 'type', 'processed_at'];
}

routes/web.php#

<?php

use App\Http\Controllers\ApiPayWebhookController;
use App\Http\Controllers\CheckoutController;
use Illuminate\Support\Facades\Route;

// La pagina del checkout es tambien la pagina de RETORNO de las pasarelas con
// redireccion: se sirve otra vez con el mismo client_secret y el widget dispara
// el segundo confirm con el token_ws.
Route::get('/checkout/{reference}', [CheckoutController::class, 'show'])->name('checkout');
Route::get('/gracias', [CheckoutController::class, 'thanks'])->name('gracias');

Route::post('/webhooks/apipay', ApiPayWebhookController::class)->name('apipay.webhook');

app/Http/Controllers/CheckoutController.php#

<?php

namespace App\Http\Controllers;

use ApiPay\ApiPayClient;
use ApiPay\Exception\ApiConnectionException;
use ApiPay\Exception\ApiException;
use App\Models\Order;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;

class CheckoutController extends Controller
{
    public function __construct(private readonly ApiPayClient $apipay)
    {
    }

    public function show(string $reference): View
    {
        $order = Order::firstOrCreate(
            ['reference' => $reference],
            // Terminado en 00: la pasarela sandbox aprueba. Prueba con 05, 13 y 42.
            ['amount_minor' => 1499000, 'currency' => 'CLP', 'status' => 'pendiente']
        );

        if (null === $order->apipay_client_secret) {
            $this->crearIntent($order);
        }

        return view('checkout', [
            'order' => $order,
            'publicKey' => (string) config('services.apipay.public_key'),
        ]);
    }

    private function crearIntent(Order $order): void
    {
        // Clave estable derivada de la orden: si hay que reintentar, se reintenta con
        // ESTA, nunca con una nueva. El SDK no reintenta POST por diseno.
        $idempotencyKey = 'orden-' . $order->reference . '-cobro';

        $body = [
            'amount_minor' => $order->amount_minor,
            'currency' => $order->currency,
            'gateway_id' => 'sandbox',
            'description' => 'Orden ' . $order->reference,
            'customer_email' => 'cliente@example.com',
            'return_url' => route('checkout', ['reference' => $order->reference]),
            'metadata' => ['order_id' => $order->reference],
        ];

        try {
            $intent = $this->apipay->paymentIntents->create($body, $idempotencyKey);
        } catch (ApiConnectionException) {
            // No consta si llego. Reintentar con la MISMA clave es seguro: la API
            // devuelve la respuesta original en lugar de crear un segundo cobro.
            $intent = $this->apipay->paymentIntents->create($body, $idempotencyKey);
        } catch (ApiException $e) {
            report($e);
            throw new HttpException(502, 'No pudimos iniciar el pago (' . $e->getErrorCode() . ')');
        }

        $order->update([
            'apipay_intent_id' => $intent->id,
            'apipay_client_secret' => $intent->client_secret,
            'apipay_idempotency_key' => $intent->getIdempotencyKey(),
        ]);
    }

    public function thanks(Request $request): View
    {
        $intentId = (string) $request->query('pi', '');
        // Un GET si se reintenta solo ante 429 y 5xx, con backoff y jitter completo.
        $intent = $this->apipay->paymentIntents->retrieve($intentId);
        $order = Order::where('apipay_intent_id', $intent->id)->first();

        return view('gracias', ['intent' => $intent, 'order' => $order]);
    }
}

app/Http/Controllers/ApiPayWebhookController.php#

<?php

namespace App\Http\Controllers;

use ApiPay\Exception\ApiPayException;
use ApiPay\Exception\ConfigurationException;
use ApiPay\Exception\SignatureVerificationException;
use ApiPay\Model\Event;
use ApiPay\Webhooks;
use App\Models\ApipayWebhookEvent;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;

class ApiPayWebhookController extends Controller
{
    public function __invoke(Request $request): Response
    {
        // Cuerpo CRUDO. Ni all(), ni json(), ni input(): se firma lo que llego.
        $payload = $request->getContent();
        $signature = (string) $request->header('ApiPay-Signature', '');
        $secret = (string) config('services.apipay.webhook_secret');

        try {
            // Verificacion estatica: comprobar un HMAC no necesita API key, y este
            // endpoint publico no debe construir un cliente con la sk_.
            $event = Webhooks::constructEvent($payload, $signature, $secret);
        } catch (SignatureVerificationException $e) {
            // Firma que no cuadra o t fuera de la tolerancia de 300 s.
            report($e);

            return response('', 400);
        } catch (ConfigurationException $e) {
            // Falta APIPAY_WEBHOOK_SECRET: es un fallo de despliegue, no del emisor.
            report($e);

            return response('', 500);
        } catch (ApiPayException $e) {
            // Firma valida pero envelope malformado.
            report($e);

            return response('', 400);
        }

        // Deduplicar por evt_ ANTES de tocar la orden, y en la MISMA transaccion que
        // el efecto de negocio: la entrega es at-least-once y la plataforma reintenta
        // hasta cinco veces (1m, 5m, 30m, 2h, 12h).
        $procesado = DB::transaction(function () use ($event): bool {
            if (ApipayWebhookEvent::where('event_id', $event->id)->lockForUpdate()->exists()) {
                return false;
            }

            ApipayWebhookEvent::create([
                'event_id' => $event->id,
                'type' => $event->type,
                'processed_at' => now(),
            ]);

            $this->aplicar($event);

            return true;
        });

        if (!$procesado) {
            // Ya visto: 2xx para que el dispatcher deje de reintentar.
            return response('', 200);
        }

        // 2xx rapido. Emails, facturacion y ERP van a una cola.
        return response('', 200);
    }

    private function aplicar(Event $event): void
    {
        $recurso = $event->data->object;

        match ($event->type) {
            Event::TYPE_PAYMENT_INTENT_SUCCEEDED => $this->marcar($recurso['id'] ?? '', 'pagada'),
            Event::TYPE_PAYMENT_INTENT_FAILED,
            Event::TYPE_PAYMENT_INTENT_EXPIRED,
            Event::TYPE_PAYMENT_INTENT_CANCELED => $this->marcar($recurso['id'] ?? '', 'fallida'),
            Event::TYPE_REFUND_SUCCEEDED => $this->marcar($recurso['payment_intent_id'] ?? '', 'reembolsada'),
            // Un tipo nuevo del catalogo es un cambio ADITIVO: ignorar sin fallar.
            default => null,
        };
    }

    private function marcar(string $intentId, string $estado): void
    {
        if ('' === $intentId) {
            return;
        }

        Order::where('apipay_intent_id', $intentId)->update(['status' => $estado]);
    }
}

resources/views/checkout.blade.php#

<!doctype html>
<html lang="es-CL">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Pagar orden {{ $order->reference }}</title>
</head>
<body>
    <h1>Orden {{ $order->reference }}</h1>
    <p>Total: {{ number_format($order->amount_minor, 0, ',', '.') }} {{ $order->currency }}</p>

    {{-- PCI DSS SAQ A: esta vista NO tiene ningun campo de tarjeta. La captura
         ocurre dentro del iframe alojado de la pasarela. --}}
    <p id="apipay-error" role="alert"></p>
    <div id="apipay-checkout"></div>

    <script src="https://checkout.apipay.io/sdk/v1.0.0/apipay.js" crossorigin="anonymous" defer></script>
    <script defer>
        addEventListener('DOMContentLoaded', function () {
            var errorBox = document.querySelector('#apipay-error');

            ApiPay.init({ publicKey: @json($publicKey) }).checkout({
                clientSecret: @json($order->apipay_client_secret),
                container: '#apipay-checkout',
                locale: 'es-CL',
                onSuccess: function (r) {
                    location.assign('/gracias?pi=' + encodeURIComponent(r.paymentIntentId));
                },
                onError: function (e) {
                    // Decidir SIEMPRE por code, nunca por el texto del mensaje.
                    errorBox.textContent = (e.code === 'intent_expired' || e.code === 'session_invalid')
                        ? 'La sesion de pago expiro. Recarga la pagina.'
                        : 'No pudimos procesar el pago. Prueba con otro medio.';
                },
                onCancel: function () { location.assign('/carro'); },
            });
        });
    </script>
</body>
</html>

@json() es lo que hay que usar para meter un valor de PHP dentro de un <script>: escapa correctamente y evita que un valor con comillas rompa el JavaScript.

resources/views/gracias.blade.php#

<!doctype html>
<html lang="es-CL">
<head><meta charset="utf-8"><title>Gracias</title></head>
<body>
    <h1>Gracias</h1>
    <p>Pago <code>{{ $intent->id }}</code> en estado <strong>{{ $intent->status }}</strong>.</p>
    <p>Orden: <strong>{{ $order?->status ?? 'desconocida' }}</strong> (la confirma el webhook).</p>
</body>
</html>

Arrancar#

php artisan migrate
php artisan serve

# En otra terminal, expon el puerto para que la plataforma alcance tu webhook:
# cloudflared tunnel --url http://localhost:8000
# ...y registra <la-url-publica>/webhooks/apipay en el backoffice.

Abre http://localhost:8000/checkout/4831 y paga.

Probar los cuatro desenlaces#

La pasarela sandbox decide por los dos últimos dígitos de amount_minor:

amount_minorQué pasaEstado final
1499000 (…00)Aprueba de inmediatoSUCCEEDED
1499005 (…05)402 card_declined en el confirmFAILED
1499013 (…13)Timeout; resuelve por webhook a los 60 sFAILED
1499042 (…42)REQUIRES_ACTION con redirect_urlSUCCEEDED o FAILED

Errores comunes en este stack#

SíntomaCausa
419 Page Expired en el webhookFalta la exclusión de CSRF
La firma nunca cuadraSe usó $request->all() o $request->json() en vez de getContent()
La firma nunca cuadra, y se usa getContent()Un middleware consumió el stream antes; o un proxy reescribe el cuerpo
500 con stack trace en el endpoint públicoAPIPAY_WEBHOOK_SECRET sin definir; captura ConfigurationException
La orden se marca pagada dos vecesFalta la UNIQUE(event_id), o el INSERT está fuera de la transacción
ConfigurationException al arrancarSe puso la pk_ en APIPAY_SECRET_KEY

Siguientes pasos#

  • Webhooks — rotación de secreto, reintentos y deduplicación.
  • Webpay Plus — el segundo confirm con token_ws.
  • Modo test — la tabla completa de sandbox.