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_minor | Qué pasa | Estado final |
|---|---|---|
1499000 (…00) | Aprueba de inmediato | SUCCEEDED |
1499005 (…05) | 402 card_declined en el confirm | FAILED |
1499013 (…13) | Timeout; resuelve por webhook a los 60 s | FAILED |
1499042 (…42) | REQUIRES_ACTION con redirect_url | SUCCEEDED o FAILED |
Errores comunes en este stack#
| Síntoma | Causa |
|---|---|
419 Page Expired en el webhook | Falta la exclusión de CSRF |
| La firma nunca cuadra | Se 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úblico | APIPAY_WEBHOOK_SECRET sin definir; captura ConfigurationException |
| La orden se marca pagada dos veces | Falta la UNIQUE(event_id), o el INSERT está fuera de la transacción |
ConfigurationException al arrancar | Se puso la pk_ en APIPAY_SECRET_KEY |
Siguientes pasos#
- Webhooks — rotación de secreto, reintentos y deduplicación.
- Webpay Plus — el segundo
confirmcontoken_ws. - Modo test — la tabla completa de
sandbox.