ROKIConnect
Conectores Empezar API SDK Migrar MCP Portal EN

Ejemplos de integración por stack

19.0 Clientes de un solo archivo, por si preferís no escribir uno

Antes de escribir un cliente a mano, sabé que ya existe uno para PHP, TypeScript y Python. Cada uno es un solo archivo sin dependencias, generado a partir de openapi.yaml y regenerado en cada despliegue, así que no puede describir un contrato que la API ya no tiene.

Lenguaje Descarga Requiere
PHP https://mcp.roki.la/sdk/RokiConnect.php PHP 8.0+, ext-curl
TypeScript / JavaScript https://mcp.roki.la/sdk/roki-connect.ts Node 18+, o cualquier runtime con fetch
Python https://mcp.roki.la/sdk/roki_connect.py Python 3.10+, solo biblioteca estándar

Traen las tres defensas que esta API necesita y que un cliente escrito a mano suele olvidar:

Cada uno también incluye verifyWebhook / verify_webhook, que calcula el HMAC sobre timestamp + "." + raw_body y compara en tiempo constante (14.4).

19.1 Si venís de otra pasarela

Si el desarrollador conoce Stripe o Mercado Pago, los errores son predecibles: escribe amount_cents, payment_method_types, notification_url, line_items. Cuarenta y cinco nombres de ese tipo están mapeados a su equivalente en ROKI - o a un explícito "no hay equivalente, hacé esto en su lugar" - y el mapeo lo aplican tanto roki_validate_request como los clientes de arriba.

Los cuatro que más importan:

En otro lado En ROKI
amount_cents: 150000 amount: 1500.00 - unidades decimales, nunca unidades menores
payment_method_types Nada. El modo es el endpoint que llamás (ver 1).
notification_url por pago Nada. Los webhooks se registran una sola vez por entorno (14).
line_items / items Nada. Sumá el carrito vos mismo y enviá un solo amount.

Preguntale a roki_validate_request con el payload que estabas por enviar; te dice de qué pasarela viene tu costumbre y cómo le llama ROKI a lo mismo.

19.2 Ejemplos completos

Cuatro integraciones completas y ejecutables. Las cuatro siguen la misma forma: credenciales desde un almacén de configuración en tiempo de ejecución, un cliente de API con idempotencia derivada del contenido, el flujo de checkout, un manejador de webhooks que verifica la firma sobre el cuerpo crudo, y una consulta periódica como respaldo.

Elegí la más cercana a tu stack; la estructura se traslada directamente a cualquier otra.

PHP / Laravel

Probado contra PHP 8.2+ y Laravel 11/12, usando la fachada Http. Todo aquello de lo que depende el dinero del cliente ocurre del lado del servidor; la llave secreta nunca llega a un navegador.

Dos reglas guían todo el diseño y vale la pena dejarlas claras desde el inicio:

La anulación, el reembolso y los recibos existen y se identifican por el UUID de la transacción (transaction_id), nunca por el id numérico del pago. El listado también existe: GET /payments acepta filtros y pagina con per_page (limit se ignora). La anulación y el reembolso también disparan webhooks cuando un humano los ejecuta en el portal, así que un manejador no debe asumir que solo sus propias llamadas cambian el estado.

1. Configuración: llaves desde una tabla de ajustes, no desde el código

Ambos secretos viven en una fila de settings, cifrados en reposo. Rotar una llave es un UPDATE, no un despliegue.

// database/migrations/2026_08_08_000100_create_settings_table.php
Schema::create('settings', function (Blueprint $table) {
    $table->id();
    $table->string('key')->unique();
    $table->text('value');           // encrypted with the app key
    $table->timestamps();
});
// config/roki.php
return [
    'base_url'        => env('ROKI_BASE_URL', 'https://aura.roki.systems/api/connect/v1'),
    'timeout'         => (int) env('ROKI_TIMEOUT', 15),
    'connect_timeout' => (int) env('ROKI_CONNECT_TIMEOUT', 5),

    // Accept-Language drives the language of validation messages. 'en' keeps logs greppable.
    // Routing errors (404 route not found, 405) are always English regardless.
    'language' => env('ROKI_LANGUAGE', 'en'),

    'keys' => [
        'secret'         => 'roki.secret_key',      // sk_test_... or sk_live_...
        'webhook_secret' => 'roki.webhook_secret',  // per environment, from the portal
    ],

    // Per-process store by default: decrypted secrets never land in a shared Redis.
    'settings_cache_store' => env('ROKI_SETTINGS_CACHE', 'array'),
    'settings_cache_ttl'   => 60,
];
// app/Support/Settings.php
namespace App\Support;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use RuntimeException;

final class Settings
{
    public function get(string $key): ?string
    {
        $store = Cache::store(config('roki.settings_cache_store'));

        return $store->remember("settings:{$key}", config('roki.settings_cache_ttl'), function () use ($key) {
            $value = DB::table('settings')->where('key', $key)->value('value');

            return $value === null ? null : Crypt::decryptString($value);
        });
    }

    public function getOrFail(string $key): string
    {
        $value = $this->get($key);

        if ($value === null || trim($value) === '') {
            // Fail closed. A blank key must never degrade into an unauthenticated call.
            throw new RuntimeException("Missing runtime setting [{$key}].");
        }

        return $value;
    }

    public function put(string $key, string $value): void
    {
        DB::table('settings')->updateOrInsert(
            ['key' => $key],
            ['value' => Crypt::encryptString($value), 'updated_at' => now(), 'created_at' => now()],
        );

        Cache::store(config('roki.settings_cache_store'))->forget("settings:{$key}");
    }
}

Rotación, sin redespliegue y sin reinicio:

// php artisan tinker
app(App\Support\Settings::class)->put('roki.secret_key', 'sk_live_...');
app(App\Support\Settings::class)->put('roki.webhook_secret', 'whsec_from_the_portal');

El TTL de la caché limita cuánto tiempo los workers viejos conservan la llave anterior: 60 segundos. Si corrés más de un nodo, mantené el TTL corto en vez de compartir un almacén de caché.

2. El cliente de la API

Primero las excepciones, para que el código que llama pueda ramificar según los modos de falla reales.

// app/Services/Roki/Exceptions.php
namespace App\Services\Roki;

use RuntimeException;

class RokiException extends RuntimeException {}

/** 401: the Authorization header is missing or the key is invalid. Operator problem, not user. */
final class RokiAuthException extends RokiException {}

/** 404 `payment_not_found`: the route exists and the identifier is well formed, the payment is not
 *  there. Nearly always an id from the other environment. */
final class RokiPaymentNotFoundException extends RokiException {}

/** 404 `transaction_id_expected`: void, refund and receipts were handed the numeric payment id
 *  where the transaction UUID belongs. The endpoint answered - only the identifier was wrong. */
final class RokiTransactionIdExpectedException extends RokiException {}

/** 404 `not_found`: the path itself does not exist in the API. Wrong base URL, missing /v1, or a
 *  removed endpoint. No change of identifier fixes it. */
final class RokiRouteNotFoundException extends RokiException {}

/** 422: validation or business rule. Carries the per-field `errors` object. */
final class RokiValidationException extends RokiException
{
    /** @param array<string, array<int, string>> $errors */
    public function __construct(string $message, private readonly array $errors = [])
    {
        parent::__construct($message);
    }

    /** @return array<string, array<int, string>> */
    public function errors(): array
    {
        return $this->errors;
    }

    /** @return array<int, string> */
    public function errorsFor(string $field): array
    {
        return $this->errors[$field] ?? [];
    }

    public function failedOn(string $field): bool
    {
        return isset($this->errors[$field]);
    }
}

/** The response came back 201/200 but a field we sent was silently dropped. */
final class RokiFieldIgnoredException extends RokiException
{
    /** @param array<int, string> $fields */
    public function __construct(public readonly array $fields)
    {
        parent::__construct('ROKI ignored these fields: '.implode(', ', $fields));
    }
}

La llave de idempotencia es una huella del contenido de la orden. Esto no es decoración: reenviar una llave con un cuerpo distinto devuelve el pago original sin ningún error - la llave gana y el cuerpo nuevo se descarta. Una llave derivada solo del id de la orden cobraría silenciosamente el monto viejo después de que el cliente edite el carrito.

// app/Services/Roki/IdempotencyKey.php
namespace App\Services\Roki;

use JsonException;

final class IdempotencyKey
{
    /**
     * Derive the key from the payload itself, so any change to the order content
     * produces a new key. Reusing a key with a changed body returns the ORIGINAL
     * payment silently - the amount you meant to charge would be discarded.
     *
     * @param array<string, mixed> $payload
     * @throws JsonException
     */
    public static function forPayload(string $scope, array $payload): string
    {
        $canonical = json_encode(
            self::sortRecursive($payload),
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
        );

        // Header max length is 191.
        return substr($scope.'-'.hash('sha256', $canonical), 0, 191);
    }

    /**
     * @param array<string, mixed> $data
     * @return array<string, mixed>
     */
    private static function sortRecursive(array $data): array
    {
        foreach ($data as $key => $value) {
            if (is_array($value)) {
                $data[$key] = self::sortRecursive($value);
            }
        }

        ksort($data);

        return $data;
    }
}

Normalizá los montos antes de calcular la huella (150.5 y 150.50 no deben producir dos llaves); el constructor del payload del paso 3 redondea a dos decimales exactamente por esa razón.

// app/Services/Roki/RokiClient.php
namespace App\Services\Roki;

use App\Support\Settings;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Throwable;

final class RokiClient
{
    public function __construct(private readonly Settings $settings) {}

    /**
     * @param array<string, mixed> $payload
     * @return array<string, mixed> the Payment object
     */
    public function createPayment(array $payload, string $idempotencyKey): array
    {
        $response = $this->request()
            ->withHeaders(['Idempotency-Key' => $idempotencyKey])
            ->post('/payments', $payload);

        return $this->decode($response);
    }

    /** @return array<string, mixed> the Payment object */
    public function getPayment(int $id): array
    {
        return $this->decode($this->request()->get("/payments/{$id}"));
    }

    /**
     * operationId listPayments - GET /payments. One page, newest first, scoped to this key's
     * merchant and environment.
     *
     * Filters: status, external_reference, from, to, page, per_page. Pass `per_page`, never
     * `limit`: the API ignores `limit` and hands back the default 20 without complaining, so a
     * sweep written with `limit` silently reads only the first 20 rows of every page.
     *
     * @param  array<string, scalar> $filters
     * @return array{data: array<int, array<string, mixed>>, meta: array<string, int>}
     */
    public function listPayments(array $filters = []): array
    {
        return $this->decode($this->request()->get('/payments', $filters));
    }

    /** 'live' or 'test' - a payment can only be read back with a key from its own environment. */
    public function environment(): string
    {
        return str_starts_with($this->secretKey(), 'sk_live_') ? 'live' : 'test';
    }

    private function secretKey(): string
    {
        $key = $this->settings->getOrFail(config('roki.keys.secret'));

        if (! str_starts_with($key, 'sk_live_') && ! str_starts_with($key, 'sk_test_')) {
            throw new RokiAuthException('Stored ROKI key has an unexpected prefix.');
        }

        return $key;
    }

    private function request(): PendingRequest
    {
        return Http::baseUrl(config('roki.base_url'))
            ->withToken($this->secretKey())              // Authorization: Bearer sk_...
            ->withHeaders(['Accept-Language' => config('roki.language')])
            ->acceptJson()
            ->asJson()
            ->connectTimeout(config('roki.connect_timeout'))
            ->timeout(config('roki.timeout'))
            // Retrying a POST is only safe because every create carries an Idempotency-Key.
            // Never retry 4xx: the request is wrong, not unlucky.
            ->retry(3, 300, function (Throwable $e): bool {
                if ($e instanceof ConnectionException) {
                    return true;
                }

                return $e instanceof RequestException && $e->response->status() >= 500;
            }, throw: false);
    }

    /** @return array<string, mixed> */
    private function decode(Response $response): array
    {
        if ($response->successful()) {
            $body = $response->json();

            if (! is_array($body)) {
                throw new RokiException('ROKI returned a non-JSON body with status '.$response->status().'.');
            }

            return $body;
        }

        $body    = is_array($response->json()) ? $response->json() : [];
        $message = is_string($body['message'] ?? null) ? $body['message'] : $response->body();
        // Branch on `code`, never on `message`: the text is localized by Accept-Language.
        $code    = is_string($body['code'] ?? null) ? $body['code'] : '';

        throw match ($response->status()) {
            401 => new RokiAuthException($message),
            // Three different 404s, and they have opposite fixes. Until 2026-08-21 the numeric-id
            // mistake also returned `not_found`, so older clients here matched the message for
            // "could not be found". That test now misfiles the most common identifier error of all
            // as "no such payment", sending you to hunt a bad id instead of a bad id TYPE.
            404 => match ($code) {
                'not_found'               => new RokiRouteNotFoundException($message),
                'transaction_id_expected' => new RokiTransactionIdExpectedException($message),
                default                   => new RokiPaymentNotFoundException($message),
            },
            422 => new RokiValidationException($message, (array) ($body['errors'] ?? [])),
            default => new RokiException("ROKI HTTP {$response->status()}: {$message}"),
        };
    }
}

En un 422, message contiene solo el primer error (con el sufijo (and N more errors)); errors los contiene todos indexados por campo. Ramificá según las llaves, nunca según el texto del mensaje - el texto se localiza con Accept-Language, las llaves son estables.

try {
    $payment = $client->createPayment($payload, $key);
} catch (RokiValidationException $e) {
    if ($e->failedOn('currency_code')) {
        // Currency not enabled on this terminal, or sandbox not provisioned for payment links.
        Log::error('ROKI rejected the currency', ['errors' => $e->errorsFor('currency_code')]);
    }

    if ($e->failedOn('expires_at')) {
        // Expiry must be in the future, in Honduras time (UTC-6).
    }

    report($e);
}

Enlazalo una sola vez:

// app/Providers/AppServiceProvider.php
$this->app->scoped(RokiClient::class, fn ($app) => new RokiClient($app->make(Settings::class)));

3. Checkout: crear al confirmar la orden, verificar y después redirigir

Espejo local del pago. GET /payments los puede listar, pero esta tabla es la que sabe cuáles seguís esperando, sin depender de una llamada de red.

// database/migrations/2026_08_08_000200_create_roki_payments_table.php
Schema::create('roki_payments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('order_id')->index();
    $table->unsignedBigInteger('payment_id')->unique();   // the ROKI id
    $table->string('status', 32)->index();                // pending|paid|partially_refunded|refunded|voided|expired|disabled
    $table->string('external_reference', 191);
    $table->decimal('amount', 12, 2);
    $table->decimal('subtotal', 12, 2);
    $table->decimal('sales_tax_amount', 12, 2)->default(0);
    $table->decimal('service_fee_amount', 12, 2)->default(0);
    $table->decimal('total', 12, 2);
    $table->string('currency_iso', 8);
    $table->text('checkout_url');
    $table->uuid('transaction_id')->nullable();          // UUID, nunca un entero
    $table->string('expires_at')->nullable();
    $table->timestamp('paid_at')->nullable();
    $table->decimal('refunded_amount', 12, 2)->nullable();
    $table->string('refund_status', 16)->nullable();      // none|partial|full
    $table->string('idempotency_key', 191);
    $table->string('key_environment', 8);                 // live|test - read it back with the same key
    $table->timestamp('next_poll_at')->nullable()->index();
    $table->unsignedSmallInteger('poll_attempts')->default(0);
    $table->timestamp('reconciled_at')->nullable();
    $table->timestamps();
});

El constructor del payload. Cada llave acá es un nombre de campo real - un error de tipeo no fallaría, dejaría pasar un pago mal configurado.

// app/Services/Roki/PaymentPayload.php
namespace App\Services\Roki;

use App\Models\Order;
use Illuminate\Support\Str;
use InvalidArgumentException;

final class PaymentPayload
{
    /** The API enforces no maximum amount. Enforce your own. */
    private const MAX_AMOUNT = 500_000.00;

    /** @return array<string, mixed> */
    public static function forOrder(Order $order): array
    {
        // Decimal units: 150.50 = L 150.50. Rounding here also keeps the
        // idempotency fingerprint stable across 150.5 / "150.50".
        $amount = round((float) $order->total, 2);

        if ($amount < 0.01 || $amount > self::MAX_AMOUNT) {
            throw new InvalidArgumentException("Order {$order->id} amount out of range: {$amount}");
        }

        $payload = [
            'amount'             => $amount,
            'external_reference' => (string) $order->id,   // NOT unique on ROKI's side
            'name'               => Str::limit("Order #{$order->number}", 191, ''),
            'description'        => Str::limit((string) $order->summary, 120, ''),
            'currency_code'      => '340',                 // HNL, ISO 4217 numeric
            'metadata'           => [
                // Carried untouched into every webhook. This is the reconciliation channel;
                // it is not shown to the customer and does not prefill the checkout form.
                'order_id'       => (string) $order->id,
                'customer_email' => (string) $order->customer_email,
            ],
            'success_url'        => route('checkout.return', $order),
            'cancel_url'         => route('cart.index'),
            // Honduras time (UTC-6), must be in the future.
            'expires_at'         => now('America/Tegucigalpa')->addHours(2)->format('Y-m-d H:i:s'),
            // Passes ROKI's commission + 3-D Secure charge on to the customer. ROKI recomputes
            // the total by reverse calculation so you net `amount`. Never replicate the formula:
            // read service_fee_amount and total from the response.
            'service_fee_enabled' => true,
        ];

        if ($order->applies_sales_tax) {
            $payload['sales_tax_type']  = 'percentage';   // none | fixed | percentage
            $payload['sales_tax_value'] = 15;             // percentage: cannot exceed 100
        }

        return array_filter($payload, static fn ($v) => $v !== null && $v !== '');
    }
}

La guarda que detecta un campo ignorado. Ejecutala en cada creación, antes de persistir nada y antes de redirigir a ningún cliente. Revisa dos cosas: el arreglo warnings de la propia API y los campos calculados - los avisos atrapan un nombre que inventaste, los campos calculados atrapan un valor que asumiste.

// app/Services/Roki/ResponseGuard.php
namespace App\Services\Roki;

use Illuminate\Support\Facades\Log;

final class ResponseGuard
{
    /**
     * @param array<string, mixed> $sent    the body we posted
     * @param array<string, mixed> $payment the Payment returned
     */
    public static function assertHonoured(array $sent, array $payment): void
    {
        $ignored = [];

        // Each check asserts "the feature we asked for is visible in the response".
        if (self::cents($payment['amount'] ?? 0) !== self::cents($sent['amount'])) {
            $ignored[] = 'amount';
        }

        if (($payment['external_reference'] ?? null) !== $sent['external_reference']) {
            $ignored[] = 'external_reference';
        }

        if (isset($sent['currency_code']) && ($payment['currency'] ?? null) !== $sent['currency_code']) {
            $ignored[] = 'currency_code';
        }

        if (isset($sent['expires_at']) && blank($payment['expires_at'] ?? null)) {
            $ignored[] = 'expires_at';
        }

        // An empty metadata may come back as [] instead of {} - both mean "none".
        if (filled($sent['metadata'] ?? null) && blank($payment['metadata'] ?? null)) {
            $ignored[] = 'metadata';
        }

        if (($sent['sales_tax_type'] ?? 'none') !== 'none' && self::cents($payment['sales_tax_amount'] ?? 0) === 0) {
            $ignored[] = 'sales_tax_type/sales_tax_value';
        }

        if (($sent['service_fee_enabled'] ?? false) === true && self::cents($payment['service_fee_amount'] ?? 0) === 0) {
            $ignored[] = 'service_fee_enabled';
        }

        if ($ignored !== []) {
            // Also fires when an Idempotency-Key collides: the API replays the original
            // payment for a changed body without erroring, so the amounts will not match.
            throw new RokiFieldIgnoredException($ignored);
        }

        if (($payment['status'] ?? null) !== 'pending') {
            throw new RokiException('Fresh payment is not pending: '.var_export($payment['status'] ?? null, true));
        }

        if (! str_starts_with((string) ($payment['checkout_url'] ?? ''), 'https://')) {
            throw new RokiException('Payment has no usable checkout_url.');
        }

        // total is authoritative. This is a consistency alarm, not a recomputation.
        $parts = self::cents($payment['subtotal'] ?? 0)
            + self::cents($payment['sales_tax_amount'] ?? 0)
            + self::cents($payment['service_fee_amount'] ?? 0);

        if (abs(self::cents($payment['total'] ?? 0) - $parts) > 1) {
            Log::warning('ROKI total does not match its parts', ['payment_id' => $payment['id'] ?? null]);
        }
    }

    private static function cents(mixed $value): int
    {
        return (int) round(((float) $value) * 100);
    }
}
// app/Services/Roki/CheckoutService.php
namespace App\Services\Roki;

use App\Models\Order;
use App\Models\RokiPayment;
use Illuminate\Support\Facades\DB;

final class CheckoutService
{
    public function __construct(private readonly RokiClient $client) {}

    /** @return string the checkout_url to redirect to */
    public function start(Order $order): string
    {
        $payload = PaymentPayload::forOrder($order);
        $key     = IdempotencyKey::forPayload("order-{$order->id}", $payload);

        // Same content as an existing live link: reuse it. external_reference is not
        // unique, so without this a double-click would leave two payable links behind.
        $existing = RokiPayment::query()
            ->where('order_id', $order->id)
            ->where('status', 'pending')
            ->where('idempotency_key', $key)
            ->latest('id')
            ->first();

        if ($existing !== null) {
            return $existing->checkout_url;
        }

        $payment = $this->client->createPayment($payload, $key);

        // Verify before persisting or redirecting. Everything downstream trusts this.
        ResponseGuard::assertHonoured($payload, $payment);

        DB::transaction(function () use ($order, $payment, $key): void {
            RokiPayment::updateOrCreate(
                ['payment_id' => (int) $payment['id']],
                [
                    'order_id'           => $order->id,
                    'status'             => $payment['status'],
                    'external_reference' => $payment['external_reference'],
                    'amount'             => $payment['amount'],
                    'subtotal'           => $payment['subtotal'],
                    'sales_tax_amount'   => $payment['sales_tax_amount'] ?? 0,
                    'service_fee_amount' => $payment['service_fee_amount'] ?? 0,
                    'total'              => $payment['total'],     // charge this, not your own sum
                    'currency_iso'       => $payment['currency_iso'],
                    'checkout_url'       => $payment['checkout_url'],
                    'expires_at'         => $payment['expires_at'] ?? null,
                    'idempotency_key'    => $key,
                    'key_environment'    => $this->client->environment(),
                    'next_poll_at'       => now()->addMinutes(2),
                ],
            );

            $order->forceFill(['payment_status' => 'awaiting_payment'])->save();
        });

        return $payment['checkout_url'];
    }
}
// app/Http/Controllers/CheckoutController.php
public function pay(Order $order, CheckoutService $checkout): RedirectResponse
{
    try {
        return redirect()->away($checkout->start($order));
    } catch (RokiValidationException $e) {
        report($e);

        return back()->withErrors(['payment' => 'We could not start the payment. Please review your order.']);
    } catch (RokiAuthException|RokiFieldIgnoredException $e) {
        // Operator-side faults: a rotated/blank key, or a payment that would have been
        // created wrong. Never send the customer to a link built on either.
        report($e);

        return back()->withErrors(['payment' => 'Payments are temporarily unavailable.']);
    }
}

/** success_url lands here. Anyone can open this URL - it is NOT proof of payment. */
public function return(Order $order, RokiClient $client, PaymentStateWriter $writer): View
{
    $row = RokiPayment::where('order_id', $order->id)->latest('id')->firstOrFail();

    try {
        $writer->apply($client->getPayment($row->payment_id), source: 'return_url');
    } catch (RokiException $e) {
        report($e);   // fall back to whatever the webhook or the poller already wrote
    }

    return view('checkout.return', ['payment' => $row->fresh()]);
}

4. Manejador de webhooks

Registrá el endpoint en el portal, en /merchant/connect/webhooks. Las URLs y los secretos de firma son separados por entorno; un secreto de sandbox nunca va a validar un evento de producción.

La ruta debe estar exenta de CSRF - ROKI no tiene token de sesión. En Laravel 11/12:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['webhooks/roki']);
})

En Laravel 10 y versiones anteriores, agregá 'webhooks/roki' a $except en app/Http/Middleware/VerifyCsrfToken. (Registrarlo en routes/api.php también evita el CSRF, pero entonces la URL del portal debe incluir el prefijo /api.)

// routes/web.php
Route::post('/webhooks/roki', RokiWebhookController::class)
    ->middleware('throttle:240,1')
    ->name('webhooks.roki');
// app/Services/Roki/SignatureVerifier.php
namespace App\Services\Roki;

final class SignatureVerifier
{
    public function __construct(private readonly int $toleranceSeconds = 300) {}

    /** Header format: t={unix_timestamp},v1={hmac_sha256_hex} */
    public function isValid(string $rawBody, ?string $header, string $secret): bool
    {
        if ($header === null || $header === '') {
            return false;
        }

        $parts = [];

        foreach (explode(',', $header) as $piece) {
            $kv = explode('=', trim($piece), 2);

            if (count($kv) === 2) {
                $parts[$kv[0]] = $kv[1];
            }
        }

        $timestamp = $parts['t'] ?? null;
        $provided  = $parts['v1'] ?? null;

        if (! is_string($timestamp) || ! ctype_digit($timestamp) || ! is_string($provided)) {
            return false;
        }

        // Replay window. Our own hardening, not an API requirement - widen it if
        // ROKI's retries legitimately arrive later than this.
        if (abs(time() - (int) $timestamp) > $this->toleranceSeconds) {
            return false;
        }

        $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

        // Constant time: a timing-sensitive === here leaks the signature byte by byte.
        return hash_equals($expected, $provided);
    }
}
// app/Http/Controllers/RokiWebhookController.php
namespace App\Http\Controllers;

use App\Jobs\ProcessRokiEvent;
use App\Services\Roki\SignatureVerifier;
use App\Support\Settings;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use JsonException;

final class RokiWebhookController extends Controller
{
    public function __construct(
        private readonly SignatureVerifier $verifier,
        private readonly Settings $settings,
    ) {}

    public function __invoke(Request $request): Response
    {
        // The RAW body, byte for byte. $request->json()/all() re-encode it and the
        // HMAC will never match - key order and whitespace are part of the signature.
        $raw = $request->getContent();

        $secret = $this->settings->getOrFail(config('roki.keys.webhook_secret'));

        if (! $this->verifier->isValid($raw, $request->header('ROKI-Signature'), $secret)) {
            Log::warning('ROKI webhook rejected: invalid signature', ['ip' => $request->ip()]);

            return response('invalid signature', 400);
        }

        try {
            /** @var array<string, mixed> $event */
            $event = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return response('invalid payload', 400);
        }

        $eventId = is_string($event['id'] ?? null) ? $event['id'] : null;
        $type    = is_string($event['type'] ?? null) ? $event['type'] : null;

        if ($eventId === null || $type === null || ! is_array($event['data'] ?? null)) {
            return response('invalid payload', 400);
        }

        // Idempotency lives on the unique index over event_id: a redelivered event
        // inserts zero rows and queues nothing.
        $isNew = DB::table('roki_webhook_events')->insertOrIgnore([
            'event_id'   => $eventId,
            'type'       => $type,
            'payload'    => $raw,
            'created_at' => $event['created_at'] ?? null,
            'received_at' => now(),
        ]) === 1;

        if ($isNew) {
            ProcessRokiEvent::dispatch($eventId);
        }

        // Fast 200. All real work happens on the queue.
        return response()->json(['received' => true]);
    }
}
// database/migrations/2026_08_08_000300_create_roki_webhook_events_table.php
Schema::create('roki_webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('event_id', 191)->unique();   // the idempotency anchor
    $table->string('type', 64);
    $table->longText('payload');
    $table->string('created_at')->nullable();    // event created_at, as sent
    $table->timestamp('received_at');
    $table->timestamp('processed_at')->nullable();
});

Un único escritor atiende tanto al webhook como al proceso de consulta periódica, de modo que los dos caminos nunca pueden contradecirse.

// app/Services/Roki/PaymentStateWriter.php
namespace App\Services\Roki;

use App\Events\OrderPaid;
use App\Models\RokiPayment;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

final class PaymentStateWriter
{
    /**
     * Monotonic ranking so a redelivered or out-of-order event cannot walk a payment
     * backwards (a late payment.approved must not overwrite a refund). Our convention,
     * built on the documented status set.
     */
    private const RANK = [
        'pending' => 0, 'expired' => 1, 'disabled' => 1, 'paid' => 2,
        'voided'  => 3, 'partially_refunded' => 3, 'refunded' => 4,
    ];

    /** @param array<string, mixed> $payment a Payment object (webhook `data` or GET response) */
    public function apply(array $payment, string $source): void
    {
        $paymentId = filter_var($payment['id'] ?? null, FILTER_VALIDATE_INT);
        $status    = $payment['status'] ?? null;

        if ($paymentId === false || ! is_string($status) || ! isset(self::RANK[$status])) {
            Log::warning('ROKI: unusable payment payload', ['source' => $source]);

            return;
        }

        $becamePaid = false;

        DB::transaction(function () use ($payment, $paymentId, $status, $source, &$becamePaid): void {
            // Match on id. external_reference is not unique and cannot identify a payment.
            $row = RokiPayment::where('payment_id', $paymentId)->lockForUpdate()->first();

            if ($row === null) {
                Log::warning('ROKI: payment not tracked locally', [
                    'payment_id'         => $paymentId,
                    'external_reference' => $payment['external_reference'] ?? null,
                    'source'             => $source,
                ]);

                return;
            }

            if (self::RANK[$status] < self::RANK[$row->status]) {
                Log::info('ROKI: stale state ignored', ['payment_id' => $paymentId, 'incoming' => $status]);

                return;
            }

            $becamePaid = $row->status !== 'paid' && $status === 'paid';

            $row->forceFill([
                'status'             => $status,
                // Re-read the money fields: a customer-selected tip is added at checkout,
                // so the final total can exceed the one returned at creation.
                'total'              => $payment['total'] ?? $row->total,
                'subtotal'           => $payment['subtotal'] ?? $row->subtotal,
                'sales_tax_amount'   => $payment['sales_tax_amount'] ?? $row->sales_tax_amount,
                'service_fee_amount' => $payment['service_fee_amount'] ?? $row->service_fee_amount,
                'transaction_id'     => $payment['transaction_id'] ?? $row->transaction_id,
                'paid_at'            => $payment['paid_at'] ?? $row->paid_at,
                'refunded_amount'    => $payment['refunded_amount'] ?? $row->refunded_amount,
                'refund_status'      => $payment['refund_status'] ?? $row->refund_status,
                'reconciled_at'      => now(),
            ])->save();

            if (in_array($status, ['refunded', 'partially_refunded', 'voided'], true)) {
                // Refund events also carry refund_amount, refunded_at and refund_reason.
                Log::info('ROKI: money returned', [
                    'payment_id'    => $paymentId,
                    'refund_amount' => $payment['refund_amount'] ?? null,
                    'refunded_at'   => $payment['refunded_at'] ?? null,
                    'refund_reason' => $payment['refund_reason'] ?? null,
                ]);
            }
        });

        if ($becamePaid) {
            // Fires exactly once, on the transition. Both webhook and poller land here.
            OrderPaid::dispatch($paymentId);
        }
    }
}
// app/Jobs/ProcessRokiEvent.php
namespace App\Jobs;

use App\Services\Roki\PaymentStateWriter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;

final class ProcessRokiEvent implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public array $backoff = [10, 30, 120, 600];

    public function __construct(private readonly string $eventId) {}

    public function handle(PaymentStateWriter $writer): void
    {
        $row = DB::table('roki_webhook_events')->where('event_id', $this->eventId)->first();

        if ($row === null || $row->processed_at !== null) {
            return;   // second line of idempotency: a retried job is a no-op
        }

        $event = json_decode($row->payload, true);

        // payment.approved | payment.failed | payment.expired
        // payment.voided | payment.refunded | payment.partially_refunded
        // The last three also arrive when the action was performed in the portal.
        $writer->apply($event['data'], source: 'webhook:'.$event['type']);

        DB::table('roki_webhook_events')
            ->where('event_id', $this->eventId)
            ->update(['processed_at' => now()]);
    }
}

5. Respaldo por consulta periódica

Los webhooks fallan: un despliegue, un parpadeo de DNS, un secreto rotado. Van dos barridos, en direcciones opuestas. El primero arranca desde tus propias filas de roki_payments y le pregunta GET /payments/{id} por cada una que seguís esperando - sale más barato que paginar y cubre exactamente lo que te importa. El segundo, más abajo, arranca desde GET /payments (9.2) y agarra lo que el primero no puede ver: un pago que ROKI tiene y tu tabla no.

// app/Console/Commands/ReconcileRokiPayments.php
namespace App\Console\Commands;

use App\Models\RokiPayment;
use App\Services\Roki\PaymentStateWriter;
use App\Services\Roki\RokiAuthException;
use App\Services\Roki\RokiClient;
use App\Services\Roki\RokiException;
use App\Services\Roki\RokiPaymentNotFoundException;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

final class ReconcileRokiPayments extends Command
{
    protected $signature   = 'roki:reconcile {--limit=200}';
    protected $description = 'Poll ROKI for payments still pending locally (webhook fallback).';

    public function handle(RokiClient $client, PaymentStateWriter $writer): int
    {
        $due = RokiPayment::query()
            ->where('status', 'pending')
            ->where('key_environment', $client->environment())   // only what this key can read
            ->where('next_poll_at', '<=', now())
            ->where('created_at', '>=', now()->subDays(3))       // give up eventually, see below
            ->orderBy('next_poll_at')
            ->limit((int) $this->option('limit'))
            ->get();

        foreach ($due as $row) {
            try {
                // Same writer as the webhook: whichever arrives first wins, the other is a no-op.
                $writer->apply($client->getPayment($row->payment_id), source: 'poll');
            } catch (RokiAuthException $e) {
                // The key is gone or was rotated to something invalid. Stop; do not
                // burn the whole backlog against a broken credential.
                Log::critical('ROKI reconciliation aborted: key rejected', ['error' => $e->getMessage()]);

                return self::FAILURE;
            } catch (RokiPaymentNotFoundException $e) {
                // "Pago no encontrado." for an id we created almost always means a
                // test/live key mix-up. Surface it, do not retry forever.
                Log::error('ROKI payment unreadable with the current key', [
                    'payment_id'      => $row->payment_id,
                    'key_environment' => $row->key_environment,
                ]);
            } catch (RokiException $e) {
                Log::warning('ROKI poll failed', ['payment_id' => $row->payment_id, 'error' => $e->getMessage()]);
            }

            $row->refresh();

            if ($row->status === 'pending') {
                // Exponential backoff, capped at an hour: 30s, 1m, 2m, ... 60m.
                $attempts = $row->poll_attempts + 1;

                $row->forceFill([
                    'poll_attempts' => $attempts,
                    'next_poll_at'  => now()->addSeconds(min(3600, 30 * (2 ** min($attempts, 7)))),
                ])->save();
            }
        }

        return self::SUCCESS;
    }
}
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('roki:reconcile')->everyMinute()->withoutOverlapping();

Después del corte, dejá de consultar y alertá a una persona: nunca adivines localmente un estado terminal. Un enlace que pasó su expires_at va a ser reportado como expired por la propia API; si días después sigue leyéndose pending, esa es una pregunta para soporte, no un valor que se inventa.

El barrido de arriba solo puede preguntar por ids que ya tiene. El de abajo le pregunta a ROKI qué tiene, que es la única manera de encontrar un pago cuya respuesta de creación se perdió: la petición llegó a ROKI, la respuesta no, y nunca se escribió una fila local.

// app/Console/Commands/SweepRokiPayments.php
namespace App\Console\Commands;

use App\Models\RokiPayment;
use App\Services\Roki\PaymentStateWriter;
use App\Services\Roki\RokiClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

final class SweepRokiPayments extends Command
{
    protected $signature   = 'roki:sweep {--days=2}';
    protected $description = 'Walk GET /payments for payments this system never recorded.';

    public function handle(RokiClient $client, PaymentStateWriter $writer): int
    {
        $from = now()->subDays((int) $this->option('days'))->toDateString();  // YYYY-MM-DD, Honduras time
        $page = 1;

        do {
            $body = $client->listPayments([
                'status'   => 'paid',
                'from'     => $from,
                'page'     => $page,
                'per_page' => 100,
            ]);

            foreach ($body['data'] as $payment) {
                $row = RokiPayment::query()->where('payment_id', $payment['id'])->first();

                if ($row === null) {
                    // Paid at ROKI, no row here. Money was taken for an order this system does not
                    // know it sold; that is a human's call, not something to auto-fulfil.
                    Log::error('ROKI payment with no local row', [
                        'payment_id'         => $payment['id'],
                        'external_reference' => $payment['external_reference'] ?? null,
                        'total'              => $payment['total'] ?? null,
                    ]);

                    continue;
                }

                // Same writer as the webhook and the poller, so a state already applied is a no-op.
                $writer->apply($payment, source: 'sweep');
            }

            $page++;
            // meta.last_page, not "an empty page": paging blind past the end wastes a request per run.
        } while ($page <= (int) ($body['meta']['last_page'] ?? 1));

        return self::SUCCESS;
    }
}
// routes/console.php
Schedule::command('roki:sweep')->hourly()->withoutOverlapping();

Cada hora alcanza: esta es la red debajo de la red, y cada página cuesta una petición. Corrélo con una ventana --days que cubra con holgura tu apagón de webhooks más largo, no la historia entera.

Cuando lo que te falta es lo otro - tenés tu id de orden pero perdiste el id del pago - filtrá en vez de paginar: $client->listPayments(['external_reference' => "order-{$order->id}"]). Leé data como una lista. external_reference no es único, y un checkout reintentado es justamente la forma en que una orden termina con varios pagos; si dos quedaron en paid, uno hay que reembolsarlo y ningún código debería elegir cuál por su cuenta.

Una prueba de humo de punta a punta que vale la pena mantener en CI, usando Http::fake():

Http::fake([
    '*/payments' => Http::response([
        'id' => 706, 'status' => 'pending', 'amount' => 100, 'subtotal' => 100,
        'sales_tax_amount' => 0, 'service_fee_amount' => 0,     // service_fee_enabled was dropped
        'total' => 100, 'currency' => '340', 'currency_iso' => 'HNL',
        'external_reference' => '1', 'checkout_url' => 'https://aura.roki.systems/pay/link/x',
        'created_at' => '2026-08-08 14:30:07',
    ], 201),
]);

$this->expectException(RokiFieldIgnoredException::class);
app(CheckoutService::class)->start($order);

Esa prueba es toda la razón de ser de ResponseGuard: es la única señal que vas a recibir de que un campo fue ignorado.


Node.js / Express (ESM, fetch nativo, Node 20+)

ESM ejecutable para Node 20+ (fetch nativo, AbortController, node:crypto). Nada está escrito en duro: la llave secreta y el secreto de firma del webhook se leen de una tabla de configuración en tiempo de ejecución, así que rotar cualquiera de las dos es una actualización de base de datos, no un redespliegue.

Dos reglas guían la mayor parte del código defensivo de abajo:

La anulación, el reembolso y los recibos existen y se identifican con el UUID de la transacción (transaction_id), nunca con el id numérico del pago. GET /payments los lista, del más nuevo al más viejo y paginado.

Almacenamiento. Postgres vía pg; adaptá el SQL a tu driver.

-- Runtime configuration. Rotating a key = UPDATE here, no deploy.
CREATE TABLE app_settings (
  key        TEXT PRIMARY KEY,
  value      TEXT        NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO app_settings (key, value) VALUES
  ('roki.secret_key',     'sk_live_...'),
  ('roki.webhook_secret', '...');   -- per environment: sandbox and live have different secrets

CREATE TABLE roki_payments (
  id                 BIGSERIAL PRIMARY KEY,
  order_id           TEXT        NOT NULL,
  idempotency_key    TEXT        NOT NULL UNIQUE,
  payment_id         BIGINT      UNIQUE,   -- Payment.id; NULL while the create call is in flight
  status             TEXT,                 -- Payment.status verbatim; NULL until the API answers
  amount             NUMERIC(14,2),
  subtotal           NUMERIC(14,2),
  sales_tax_amount   NUMERIC(14,2),
  service_fee_amount NUMERIC(14,2),
  total              NUMERIC(14,2),
  currency           TEXT,
  currency_iso       TEXT,
  external_reference TEXT,
  checkout_url       TEXT,
  transaction_id     TEXT,          -- UUID, never an integer
  refunded_amount    NUMERIC(14,2),
  refund_status      TEXT,
  expires_at         TIMESTAMPTZ,
  paid_at            TIMESTAMPTZ,
  poll_attempts      INT         NOT NULL DEFAULT 0,
  next_poll_at       TIMESTAMPTZ,
  needs_review       BOOLEAN     NOT NULL DEFAULT false,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX roki_payments_poll_idx ON roki_payments (next_poll_at) WHERE status = 'pending';

-- One row per webhook event id: this table IS the idempotency guard.
CREATE TABLE roki_webhook_events (
  event_id     TEXT PRIMARY KEY,     -- WebhookEvent.id
  type         TEXT        NOT NULL,
  payment_id   BIGINT,
  payload      JSONB       NOT NULL,
  received_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at TIMESTAMPTZ
);

1. Configuración desde un almacén de ajustes

El prefijo de la llave por sí solo selecciona el entorno (sk_test_ sandbox, sk_live_ producción) sobre rutas idénticas, así que el entorno se deriva, nunca se configura aparte. Un caché con TTL corto mantiene la ruta caliente fuera de la base de datos y, aun así, recoge una llave rotada en menos de un minuto.

// src/config/settings.js
import pg from 'pg';

export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });

const TTL_MS = 60_000;
const cache = new Map(); // key -> { value, expiresAt }

/** Reads a setting from the runtime store. Cached briefly so rotation propagates on its own. */
export async function getSetting(key) {
  const now = Date.now();
  const hit = cache.get(key);
  if (hit && hit.expiresAt > now) return hit.value;

  const { rows } = await db.query('SELECT value FROM app_settings WHERE key = $1', [key]);
  const value = rows[0]?.value?.trim();
  if (!value) {
    cache.delete(key);
    throw new Error(`Missing runtime setting "${key}". Insert it into app_settings.`);
  }
  cache.set(key, { value, expiresAt: now + TTL_MS });
  return value;
}

/** Call after an admin rotates a credential so the change is visible immediately. */
export function invalidateSettings() {
  cache.clear();
}

export const ROKI_BASE_URL = 'https://aura.roki.systems/api/connect/v1';

/** Enforce a ceiling of your own: the API accepts arbitrarily large amounts. */
export const MAX_AMOUNT = 500_000; // L 500,000.00

export async function rokiConfig() {
  const secretKey = await getSetting('roki.secret_key');
  if (!/^sk_(test|live)_/.test(secretKey)) {
    throw new Error('roki.secret_key must start with sk_test_ or sk_live_.');
  }
  return {
    baseUrl: ROKI_BASE_URL,
    secretKey,
    live: secretKey.startsWith('sk_live_'),
    timeoutMs: 15_000,
  };
}

/** Signing secret for the webhook endpoint. Sandbox and live have separate secrets. */
export const rokiWebhookSecret = () => getSetting('roki.webhook_secret');

La llave secreta es exclusivamente del lado del servidor. Nunca debe llegar a un bundle de navegador, a una aplicación móvil ni a una variable de entorno del lado del cliente.

2. Cliente de la API

createPayment() y getPayment(), con un tiempo de espera por intento mediante AbortController, un Idempotency-Key derivado del contenido, reintentos que reutilizan esa llave, y errores tipados que llevan el objeto errors por campo de un 422.

// src/roki/errors.js
export class RokiError extends Error {
  constructor(message, { status = null, body = null, retryable = false } = {}) {
    super(message);
    this.name = new.target.name;
    this.status = status;
    this.body = body;
    this.retryable = retryable;
  }
}

/** 401 - Authorization header missing or key invalid. Never retry; page an operator. */
export class RokiAuthError extends RokiError {}

/** 404 `payment_not_found` - the route exists and the identifier is well formed, the payment is
 *  not there (wrong id, or a key from the other environment). */
export class RokiPaymentNotFoundError extends RokiError {}

/** 404 `transaction_id_expected` - void, refund and receipts are keyed by the transaction UUID,
 *  not the numeric payment id. The endpoint exists and rejected the identifier. */
export class RokiTransactionIdExpectedError extends RokiError {}

/** 404 `not_found` - the path itself does not exist: wrong base URL, missing /v1, or a removed
 *  endpoint. This is the only error whose message stays English. */
export class RokiRouteNotFoundError extends RokiError {}

/** 422 - validation or business rule. `fieldErrors` is the API `errors` object:
 *  { field: [message, ...] }. The keys are stable; the messages are localized. */
export class RokiValidationError extends RokiError {
  constructor(message, { status, body }) {
    super(message, { status, body, retryable: false });
    this.fieldErrors = body?.errors ?? {};
  }
  /** e.g. hasField('currency_code') -> currency not enabled on the terminal. */
  hasField(field) {
    return Object.hasOwn(this.fieldErrors, field);
  }
  flatten() {
    return Object.entries(this.fieldErrors)
      .map(([field, msgs]) => `${field}: ${msgs.join(' ')}`)
      .join(' | ');
  }
}

/** Request aborted by our own timeout. Retryable: the payment may or may not exist,
 *  which is exactly what the Idempotency-Key resolves. */
export class RokiTimeoutError extends RokiError {}

/** DNS/TLS/socket failure, or an undocumented status. */
export class RokiTransportError extends RokiError {}
// src/roki/client.js
import { createHash } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { rokiConfig, MAX_AMOUNT } from '../config/settings.js';
import {
  RokiAuthError, RokiPaymentNotFoundError, RokiRouteNotFoundError,
  RokiTransactionIdExpectedError,
  RokiValidationError, RokiTimeoutError, RokiTransportError,
} from './errors.js';

/** Exactly the fields PaymentCreateRequest defines. Anything else is silently
 *  ignored by the API, so we reject it here instead of shipping a broken payment. */
const CREATE_FIELDS = new Set([
  'amount', 'external_reference', 'name', 'currency_code', 'description', 'metadata',
  'success_url', 'cancel_url', 'expires_at',
  'sales_tax_type', 'sales_tax_value',
  'tip_enabled', 'tip_type', 'tip_value', 'tip_customer_selectable',
  'tip_preset_percentages', 'tip_allow_custom', 'tip_min_amount', 'tip_max_amount',
  'service_fee_enabled', 'reusable',
]);

/** Amounts are DECIMAL units (150.50 = L 150.50), never cents. */
export const money = (n) => Math.round(Number(n) * 100) / 100;
const cents = (n) => Math.round(Number(n) * 100);

function assertCreatePayload(payload) {
  const unknown = Object.keys(payload).filter((k) => !CREATE_FIELDS.has(k));
  if (unknown.length) {
    // The API would answer 201 and drop these. Fail loudly at build time instead.
    throw new TypeError(`Unknown ROKI field(s): ${unknown.join(', ')}`);
  }
  for (const required of ['amount', 'external_reference', 'name']) {
    if (payload[required] === undefined || payload[required] === null || payload[required] === '') {
      throw new TypeError(`"${required}" is required by PaymentCreateRequest.`);
    }
  }
  if (!(payload.amount >= 0.01) || payload.amount > MAX_AMOUNT) {
    throw new RangeError(`amount out of range: ${payload.amount} (0.01 .. ${MAX_AMOUNT})`);
  }
  if (payload.external_reference.length > 191) throw new RangeError('external_reference max length is 191.');
  if (payload.name.length > 191) throw new RangeError('name max length is 191.');
  if (payload.description && payload.description.length > 120) throw new RangeError('description max length is 120.');
}

/** Stable JSON: sorted keys, so two logically equal bodies hash identically. */
function canonical(value) {
  if (Array.isArray(value)) return value.map(canonical);
  if (value && typeof value === 'object') {
    return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])]));
  }
  return value;
}

/**
 * Idempotency key derived from the ORDER CONTENT, not from its id.
 *
 * Verified caveat: replaying a key with a different body returns the ORIGINAL payment
 * with no error at all (no 422). If the key were `order-1001`, an order edited from
 * L 150 to L 900 would silently reuse the L 150 payment and you would under-charge.
 * Hashing the exact body means any change to amount, tax, tip or fee yields a new key.
 */
export function idempotencyKeyFor(payload) {
  const digest = createHash('sha256').update(JSON.stringify(canonical(payload))).digest('hex');
  const ref = String(payload.external_reference).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 100);
  return `${ref}.${digest.slice(0, 32)}`; // well under the 191-char limit
}

async function request(path, { method = 'GET', payload, idempotencyKey, timeoutMs } = {}) {
  const cfg = await rokiConfig();
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs ?? cfg.timeoutMs);

  const headers = {
    Authorization: `Bearer ${cfg.secretKey}`,
    Accept: 'application/json',
    // Keys of the `errors` object are stable; only the message text is localized.
    // English keeps logs greppable.
    'Accept-Language': 'en',
  };
  if (payload !== undefined) headers['Content-Type'] = 'application/json';
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  let res;
  try {
    res = await fetch(`${cfg.baseUrl}${path}`, {
      method,
      headers,
      body: payload === undefined ? undefined : JSON.stringify(payload),
      signal: controller.signal,
    });
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new RokiTimeoutError(`ROKI ${method} ${path} timed out`, { retryable: true });
    }
    throw new RokiTransportError(`ROKI ${method} ${path} failed: ${err.message}`, { retryable: true });
  } finally {
    clearTimeout(timer);
  }

  const text = await res.text();
  let body = null;
  try { body = text ? JSON.parse(text) : null; } catch { /* non-JSON error page */ }

  if (res.ok) {
    if (!body || typeof body !== 'object') {
      throw new RokiTransportError('ROKI returned a non-JSON success body', { status: res.status, retryable: true });
    }
    return body;
  }

  const message = body?.message ?? text.slice(0, 300);

  switch (res.status) {
    case 401:
      // Two distinct texts: missing Authorization header vs. "Clave API invalida."
      throw new RokiAuthError(`ROKI 401: ${message}`, { status: 401, body });
    case 404:
      // Three 404s with three different fixes, separated by `code`. Do NOT test the message for
      // "could not be found": since 2026-08-21 the numeric-id mistake no longer produces that text,
      // so the old test quietly reclassifies it as "payment does not exist".
      if (body?.code === 'not_found') {
        throw new RokiRouteNotFoundError(`ROKI route does not exist: ${method} ${path}`, { status: 404, body });
      }
      if (body?.code === 'transaction_id_expected') {
        throw new RokiTransactionIdExpectedError(
          `ROKI ${method} ${path} needs the transaction UUID, not the numeric payment id`,
          { status: 404, body },
        );
      }
      throw new RokiPaymentNotFoundError(`ROKI 404: ${message}`, { status: 404, body });
    case 422:
      throw new RokiValidationError(`ROKI 422: ${message}`, { status: 422, body });
    default:
      // The spec documents 201/401/422 on create and 200/401/404 on retrieve.
      // Anything else is unexpected; treat >=500 as transient.
      throw new RokiTransportError(`ROKI ${res.status}: ${message}`, {
        status: res.status, body, retryable: res.status >= 500,
      });
  }
}

const RETRY_DELAYS_MS = [400, 1200];

/**
 * POST /payments -> 201 Payment.
 * Retries reuse the SAME Idempotency-Key: that is the whole point of the header.
 * A retry after a timeout returns the payment the first attempt may already have created.
 */
export async function createPayment(payload, { idempotencyKey } = {}) {
  assertCreatePayload(payload);
  const key = idempotencyKey ?? idempotencyKeyFor(payload);

  for (let attempt = 0; ; attempt++) {
    try {
      return await request('/payments', { method: 'POST', payload, idempotencyKey: key });
    } catch (err) {
      if (!err.retryable || attempt >= RETRY_DELAYS_MS.length) throw err;
      await sleep(RETRY_DELAYS_MS[attempt]);
    }
  }
}

/** GET /payments/{id} -> 200 Payment. Source of truth for a charge, together with webhooks. */
export async function getPayment(paymentId) {
  const id = Number(paymentId);
  if (!Number.isInteger(id) || id < 1) throw new TypeError(`Invalid payment id: ${paymentId}`);

  for (let attempt = 0; ; attempt++) {
    try {
      return await request(`/payments/${id}`);
    } catch (err) {
      if (!err.retryable || attempt >= RETRY_DELAYS_MS.length) throw err;
      await sleep(RETRY_DELAYS_MS[attempt]);
    }
  }
}

/** metadata comes back as {} or, when none was sent, sometimes []. Both mean "no metadata". */
export const readMetadata = (payment) =>
  (payment.metadata && !Array.isArray(payment.metadata)) ? payment.metadata : {};

/** expires_at is Honduras wall-clock time (UTC-6, no DST). */
export function hondurasExpiry(minutesFromNow) {
  const t = Date.now() + minutesFromNow * 60_000 - 6 * 3_600_000;
  return new Date(t).toISOString().slice(0, 19).replace('T', ' '); // "YYYY-MM-DD HH:MM:SS"
}

export function fromHondurasTime(value) {
  if (!value) return null;
  if (/[Zz]|[+-]\d{2}:?\d{2}$/.test(value)) return new Date(value); // already offset-qualified
  return new Date(`${value.replace(' ', 'T')}-06:00`);
}

export { cents };

Manejar un 422 en el punto de llamada se ramifica según las llaves estables de errors, no según el texto localizado:

try {
  payment = await createPayment(payload);
} catch (err) {
  if (err instanceof RokiValidationError) {
    // e.g. currency_code -> currency not enabled on the terminal, or sandbox not
    // provisioned for payment links; expires_at -> date in the past;
    // tip_max_amount -> greater than `amount`.
    req.log.warn({ fields: err.fieldErrors }, 'ROKI rejected the payment payload');
    return res.status(400).render('checkout/error', { detail: err.flatten() });
  }
  throw err;
}

3. Flujo de checkout

Creá el pago cuando la orden se confirma, reclamá la llave de idempotencia en la base de datos antes de la llamada de red para que un formulario enviado dos veces no pueda producir dos pagos, persistí el pago, verificá los campos calculados y recién ahí redirigí.

// src/checkout/createRokiPayment.js
import { db } from '../config/settings.js';
import { createPayment, idempotencyKeyFor, hondurasExpiry, fromHondurasTime, cents } from '../roki/client.js';

const CHECKOUT_HOST = 'aura.roki.systems';

function buildPayload(order) {
  return {
    amount: order.subtotal,                 // decimal units, NOT cents
    external_reference: `order-${order.id}`, // not unique on the API side
    name: `Order #${order.id}`,
    description: order.tableLabel?.slice(0, 120),
    currency_code: '340',                   // HNL
    metadata: {                             // returned untouched on GET and in every webhook
      order_id: String(order.id),
      customer_email: order.customerEmail,
    },
    success_url: `${process.env.PUBLIC_URL}/orders/${order.id}/thank-you`,
    cancel_url: `${process.env.PUBLIC_URL}/cart`,
    expires_at: hondurasExpiry(60),
    sales_tax_type: 'percentage',
    sales_tax_value: 15,
    service_fee_enabled: true,              // exact name; variants land in `warnings`, not an error
    reusable: false,
  };
}

/**
 * Verifies that the payment ROKI created is the payment we asked for.
 * A dropped field produces a 201 and a payment missing tax, tip or fee. The API
 * now names what it dropped in `warnings`; this checks the computed values too,
 * because a field that was understood but computed differently raises no warning.
 */
function verifyComputedFields(payment, sent) {
  const problems = [];

  if (cents(payment.amount) !== cents(sent.amount)) problems.push(`amount ${payment.amount} != ${sent.amount}`);
  if (payment.external_reference !== sent.external_reference) problems.push('external_reference mismatch');
  if (sent.currency_code && payment.currency !== sent.currency_code) problems.push(`currency ${payment.currency}`);
  if (payment.currency_iso !== 'HNL') problems.push(`currency_iso ${payment.currency_iso}`);
  if (payment.status !== 'pending') problems.push(`status ${payment.status}`);
  if (sent.reusable !== undefined && payment.reusable !== sent.reusable) problems.push('reusable was dropped');

  const taxRequested = sent.sales_tax_type && sent.sales_tax_type !== 'none';
  if (taxRequested && cents(payment.sales_tax_amount) === 0) problems.push('sales_tax_amount is 0 but tax was requested');
  if (!taxRequested && cents(payment.sales_tax_amount) !== 0) problems.push('unexpected sales_tax_amount');

  if (sent.service_fee_enabled === true && cents(payment.service_fee_amount) <= 0) {
    problems.push('service_fee_amount is 0 but service_fee_enabled was sent');
  }
  if (sent.service_fee_enabled !== true && cents(payment.service_fee_amount) !== 0) {
    problems.push('unexpected service_fee_amount');
  }

  // total = subtotal + sales_tax_amount + service_fee_amount. Only assert it when no
  // fixed tip is configured: a fixed tip contributes to the total outside these fields,
  // and a customer-selectable tip is added later, at checkout.
  if (!sent.tip_type && !sent.tip_value) {
    const expected = cents(payment.subtotal) + cents(payment.sales_tax_amount) + cents(payment.service_fee_amount);
    if (cents(payment.total) !== expected) problems.push(`total ${payment.total} != breakdown ${expected / 100}`);
  }

  if (!payment.checkout_url || new URL(payment.checkout_url).hostname !== CHECKOUT_HOST) {
    problems.push(`unexpected checkout_url host`);
  }

  return problems;
}

export async function createRokiPaymentForOrder(order, log) {
  const payload = buildPayload(order);
  const key = idempotencyKeyFor(payload);

  // Claim the attempt first. The UNIQUE index turns a double-submit into a no-op.
  const claim = await db.query(
    `INSERT INTO roki_payments (order_id, idempotency_key, external_reference)
     VALUES ($1, $2, $3)
     ON CONFLICT (idempotency_key) DO NOTHING
     RETURNING id`,
    [order.id, key, payload.external_reference],
  );

  if (claim.rowCount === 0) {
    const { rows } = await db.query('SELECT * FROM roki_payments WHERE idempotency_key = $1', [key]);
    // Already created: reuse the link instead of creating a second payment.
    if (rows[0]?.checkout_url) return rows[0];
    // Still in flight (or a previous attempt crashed): safe to call again with the same
    // key, the API returns the same payment.
  }

  const payment = await createPayment(payload, { idempotencyKey: key });

  const problems = verifyComputedFields(payment, payload);
  if (problems.length) {
    // The payment exists and is chargeable, so do not silently proceed: flag it and stop.
    await db.query('UPDATE roki_payments SET needs_review = true WHERE idempotency_key = $1', [key]);
    log.error({ payment_id: payment.id, problems }, 'ROKI payment does not match the request');
    throw new Error(`ROKI payment ${payment.id} failed verification: ${problems.join('; ')}`);
  }

  const { rows } = await db.query(
    `UPDATE roki_payments SET
        payment_id = $2, status = $3, amount = $4, subtotal = $5, sales_tax_amount = $6,
        service_fee_amount = $7, total = $8, currency = $9, currency_iso = $10,
        checkout_url = $11, transaction_id = $12, expires_at = $13, paid_at = $14,
        next_poll_at = now() + interval '2 minutes', updated_at = now()
      WHERE idempotency_key = $1
      RETURNING *`,
    [
      key, payment.id, payment.status, payment.amount, payment.subtotal,
      payment.sales_tax_amount ?? 0, payment.service_fee_amount ?? 0, payment.total,
      payment.currency, payment.currency_iso, payment.checkout_url,
      payment.transaction_id, fromHondurasTime(payment.expires_at), fromHondurasTime(payment.paid_at),
    ],
  );

  log.info({ payment_id: payment.id, total: payment.total }, 'ROKI payment created');
  return rows[0];
}
// src/routes/checkout.js
import { Router } from 'express';
import { createRokiPaymentForOrder } from '../checkout/createRokiPayment.js';
import { RokiAuthError, RokiValidationError } from '../roki/errors.js';

export const checkout = Router();

checkout.post('/orders/:id/pay', async (req, res, next) => {
  try {
    const order = await loadConfirmedOrder(req.params.id, req.user);
    if (order.paidAt) return res.redirect(303, `/orders/${order.id}/thank-you`);

    const row = await createRokiPaymentForOrder(order, req.log);

    // Charge shown to the customer is `total`, not `amount`. With
    // tip_customer_selectable it can still grow at checkout.
    await linkOrderToPayment(order.id, row.payment_id, row.total);

    return res.redirect(303, row.checkout_url);
  } catch (err) {
    if (err instanceof RokiValidationError) {
      req.log.warn({ fields: err.fieldErrors }, 'ROKI validation failed');
      return res.status(400).render('checkout/error', { detail: err.flatten() });
    }
    if (err instanceof RokiAuthError) {
      req.log.error('ROKI key rejected - rotate or fix roki.secret_key in app_settings');
      return res.status(503).render('checkout/unavailable');
    }
    return next(err);
  }
});

Que el cliente llegue a success_url no es una confirmación: cualquiera puede navegar a esa URL. La página de agradecimiento debe leer el estado guardado localmente, que solo lo fija el webhook o el proceso de consulta periódica.

4. Manejador del webhook

La firma se calcula sobre los bytes crudos de la petición. express.json() consume el stream y te deja solamente un objeto ya parseado; volver a serializarlo con JSON.stringify no es idéntico byte a byte a lo que se firmó. El orden de las llaves puede cambiar, los espacios en blanco insignificantes desaparecen, 105.50 se vuelve 105.5, y el escapado de caracteres no ASCII difiere. Un solo byte distinto cambia el HMAC, así que todos los webhooks fallarían la verificación. Montá express.raw() en la ruta del webhook, antes de cualquier parser global de JSON.

// src/app.js
import express from 'express';
import { rokiWebhook } from './webhooks/roki.js';
import { checkout } from './routes/checkout.js';

export const app = express();

// 1) The ROKI webhook MUST receive the raw body. This route is registered BEFORE the
//    global JSON parser, and uses express.raw() so req.body is a Buffer of the exact
//    bytes ROKI signed. Do not move it below express.json().
app.post('/webhooks/roki', express.raw({ type: '*/*', limit: '1mb' }), rokiWebhook);

// 2) Everything else parses JSON normally.
app.use(express.json());
app.use(checkout);

Si un parser global es inevitable (montado por un framework que no controlás), capturá el buffer en su lugar y verificá contra req.rawBody:

app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
// src/webhooks/verify.js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300; // replay window; our choice, not an API rule

/**
 * ROKI-Signature: t={unix_timestamp},v1={hmac_sha256_hex}
 * Expected value: HMAC-SHA256(timestamp + "." + raw_body, signing_secret)
 */
export function verifyRokiSignature(rawBody, header, secret, now = Date.now()) {
  if (!Buffer.isBuffer(rawBody) || !header || !secret) return false;

  const parts = {};
  for (const piece of header.split(',')) {
    const eq = piece.indexOf('=');
    if (eq > 0) parts[piece.slice(0, eq).trim()] = piece.slice(eq + 1).trim();
  }

  const { t, v1 } = parts;
  if (!/^\d+$/.test(t ?? '')) return false;
  if (!/^[0-9a-f]{64}$/i.test(v1 ?? '')) return false; // reject before Buffer.from(hex)

  // Reject stale or far-future timestamps: the signed payload includes `t`, so an
  // attacker cannot replay an old body under a fresh timestamp.
  if (Math.abs(Math.floor(now / 1000) - Number(t)) > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  const received = Buffer.from(v1, 'hex');

  // timingSafeEqual throws on length mismatch; both are SHA-256 digests, but check anyway.
  if (received.length !== expected.length) return false;
  return timingSafeEqual(expected, received);
}
// src/webhooks/roki.js
import { db } from '../config/settings.js';
import { rokiWebhookSecret } from '../config/settings.js';
import { verifyRokiSignature } from './verify.js';
import { applyPaymentState } from '../roki/applyPaymentState.js';

export async function rokiWebhook(req, res) {
  const raw = req.body; // Buffer, thanks to express.raw()
  if (!Buffer.isBuffer(raw)) {
    req.log.error('Webhook body is not a Buffer - express.json() is stealing the raw body');
    return res.status(400).json({ error: 'raw body unavailable' });
  }

  let secret;
  try {
    secret = await rokiWebhookSecret();
  } catch {
    // Do not 400 a possibly valid event because of our own misconfiguration:
    // 5xx makes ROKI retry once the secret is in place.
    return res.status(503).json({ error: 'signing secret unavailable' });
  }

  if (!verifyRokiSignature(raw, req.get('ROKI-Signature'), secret)) {
    req.log.warn({ ip: req.ip }, 'Rejected ROKI webhook: invalid signature');
    return res.status(400).json({ error: 'invalid signature' });
  }

  let event;
  try {
    event = JSON.parse(raw.toString('utf8'));
  } catch {
    return res.status(400).json({ error: 'invalid JSON' });
  }
  if (!event?.id || !event?.type || !event?.data?.id) {
    return res.status(400).json({ error: 'malformed event' });
  }

  // Idempotency: an event can be redelivered. The primary key on event_id is the guard.
  const claim = await db.query(
    `INSERT INTO roki_webhook_events (event_id, type, payment_id, payload)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (event_id) DO NOTHING
     RETURNING event_id`,
    [event.id, event.type, event.data.id, event],
  );

  if (claim.rowCount === 0) {
    req.log.info({ event_id: event.id }, 'Duplicate ROKI event ignored');
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Answer fast; the event is durably stored, so processing can finish afterwards.
  res.status(200).json({ received: true });

  try {
    await handleEvent(event, req.log);
    await db.query('UPDATE roki_webhook_events SET processed_at = now() WHERE event_id = $1', [event.id]);
  } catch (err) {
    // Left with processed_at NULL: a sweeper reprocesses these from the stored payload.
    req.log.error({ err, event_id: event.id }, 'ROKI event processing failed');
  }
}

async function handleEvent(event, log) {
  const payment = event.data; // the full Payment object
  const changed = await applyPaymentState(payment, { source: `webhook:${event.type}` });

  switch (event.type) {
    case 'payment.approved':
      if (changed) await fulfillOrder(payment);        // only on the transition into paid
      break;
    case 'payment.failed':
      // Retrying needs a NEW payment: the idempotency key is content-derived, so an
      // unchanged order reuses the same key and would return the failed payment.
      await markOrderPaymentFailed(payment);
      break;
    case 'payment.expired':
      await releaseOrderReservation(payment);
      break;
    case 'payment.voided':
    case 'payment.refunded':
    case 'payment.partially_refunded':
      // These arrive whether the reversal came from your own call to
      // POST /payments/{transaction_id}/void or /refund, or from a human in the
      // merchant portal. Do not assume "we did not call it, so it did not happen".
      // Refund events additionally carry refund_amount, refunded_at and
      // refund_reason alongside the payment.
      log.info({
        payment_id: payment.id,
        refund_status: payment.refund_status,
        refunded_amount: payment.refunded_amount,
        refund_amount: event.data.refund_amount,
        refunded_at: event.data.refunded_at,
        refund_reason: event.data.refund_reason,
      }, 'ROKI reversal received');
      await reverseOrder(payment);
      break;
    default:
      log.warn({ type: event.type }, 'Unhandled ROKI event type');
  }
}

La máquina de estados se comparte con el proceso de consulta periódica para que ambos caminos converjan, y las entregas fuera de orden o reenviadas no pueden hacer retroceder un pago:

// src/roki/applyPaymentState.js
import { db } from '../config/settings.js';
import { fromHondurasTime, cents } from './client.js';

// Higher rank never regresses. `expired` must not overwrite `paid`.
const RANK = {
  pending: 0, disabled: 1, expired: 1, paid: 2, voided: 3, partially_refunded: 3, refunded: 4,
};

/** Returns true when the stored status actually advanced (side effects hang off that). */
export async function applyPaymentState(payment, { source }) {
  if (!Object.hasOwn(RANK, payment.status)) throw new Error(`Unknown ROKI status: ${payment.status}`);

  const client = await db.connect();
  try {
    await client.query('BEGIN');
    const { rows } = await client.query(
      'SELECT status, total FROM roki_payments WHERE payment_id = $1 FOR UPDATE',
      [payment.id],
    );
    const current = rows[0];
    if (!current) {           // a payment we never stored: created elsewhere, or the portal
      await client.query('ROLLBACK');
      return false;
    }
    if (RANK[payment.status] < RANK[current.status ?? 'pending']) {
      await client.query('ROLLBACK');
      return false;           // stale delivery
    }

    // A customer-selected tip is added at checkout, so the paid total may EXCEED the
    // created total. Only a total lower than what we created is suspicious.
    const needsReview = cents(payment.total) < cents(current.total ?? payment.total);

    await client.query(
      `UPDATE roki_payments SET
         status = $2, total = $3, sales_tax_amount = $4, service_fee_amount = $5,
         transaction_id = $6, paid_at = $7, refunded_amount = $8, refund_status = $9,
         needs_review = needs_review OR $10, next_poll_at = NULL, updated_at = now()
       WHERE payment_id = $1`,
      [
        payment.id, payment.total, payment.sales_tax_amount ?? 0, payment.service_fee_amount ?? 0,
        payment.transaction_id, fromHondurasTime(payment.paid_at),
        payment.refunded_amount ?? null, payment.refund_status ?? null, needsReview,
      ],
    );
    await client.query('COMMIT');
    return (current.status ?? 'pending') !== payment.status;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

5. Respaldo por consulta periódica

Los webhooks fallan: tu endpoint se cae durante un despliegue, la entrega se pierde, el DNS parpadea. GET /payments/{id} es la otra fuente de verdad, y GET /payments lista un rango de fechas. Consultá los ids desde tu propia tabla con un backoff, y canalizá los resultados por el mismo applyPaymentState.

// src/roki/poller.js
import { db } from '../config/settings.js';
import { getPayment } from './client.js';
import { applyPaymentState } from './applyPaymentState.js';
import { RokiAuthError, RokiPaymentNotFoundError } from './errors.js';

const BACKOFF_MINUTES = [1, 2, 5, 10, 30, 60];

export async function pollPendingPayments(log, { batch = 50 } = {}) {
  const { rows } = await db.query(
    `SELECT payment_id, poll_attempts, expires_at
       FROM roki_payments
      WHERE status = 'pending'
        AND payment_id IS NOT NULL
        AND created_at > now() - interval '7 days'
        AND (next_poll_at IS NULL OR next_poll_at <= now())
      ORDER BY next_poll_at NULLS FIRST
      LIMIT $1
      FOR UPDATE SKIP LOCKED`,
    [batch],
  );

  for (const row of rows) {
    try {
      const payment = await getPayment(row.payment_id);
      const changed = await applyPaymentState(payment, { source: 'poll' });

      if (changed && payment.status === 'paid') {
        // The webhook never arrived (or arrived first and this is a no-op).
        await fulfillOrder(payment);
        log.warn({ payment_id: payment.id }, 'Payment confirmed by polling, not by webhook');
      }
      if (payment.status === 'pending') await scheduleNextPoll(row);
    } catch (err) {
      if (err instanceof RokiAuthError) {
        // The key was rotated or revoked: every further call fails. Stop the run.
        log.error('Polling aborted - roki.secret_key rejected');
        return;
      }
      if (err instanceof RokiPaymentNotFoundError) {
        // The route exists, this payment does not: usually a key from the OTHER
        // environment (sk_test_ vs sk_live_) after a rotation.
        await db.query(
          'UPDATE roki_payments SET needs_review = true, next_poll_at = NULL WHERE payment_id = $1',
          [row.payment_id],
        );
        log.error({ payment_id: row.payment_id }, 'Payment not found for the key in use');
        continue;
      }
      log.warn({ err, payment_id: row.payment_id }, 'Poll attempt failed');
      await scheduleNextPoll(row);
    }
  }
}

async function scheduleNextPoll(row) {
  const attempts = row.poll_attempts + 1;
  const delay = BACKOFF_MINUTES[Math.min(attempts, BACKOFF_MINUTES.length - 1)];

  // Stop once the link is well past expiry: the API moves it to `expired` on its own
  // and emits payment.expired.
  const giveUp = row.expires_at && row.expires_at.getTime() + 30 * 60_000 < Date.now();

  await db.query(
    `UPDATE roki_payments
        SET poll_attempts = $2,
            next_poll_at  = CASE WHEN $3 THEN NULL ELSE now() + ($4 || ' minutes')::interval END
      WHERE payment_id = $1`,
    [row.payment_id, attempts, giveUp, String(delay)],
  );
}

Ejecutalo desde un planificador que garantice un único ejecutor (cron más un advisory lock, o tu cola de trabajos). Un intervalo dentro del proceso está bien para una sola instancia:

// src/index.js
import { app } from './app.js';
import { pollPendingPayments } from './roki/poller.js';
import { logger } from './logger.js';

app.listen(3000, () => logger.info('listening on :3000'));

setInterval(() => {
  pollPendingPayments(logger).catch((err) => logger.error({ err }, 'poller crashed'));
}, 60_000).unref();

Python / FastAPI (httpx, asíncrono, con tipado completo; pydantic v2 + SQLAlchemy 2.0 async)

Integración asíncrona con httpx, con tipado completo. Requiere Python 3.11+, fastapi, httpx, pydantic v2, sqlalchemy 2.0 (async) y tzdata en Windows.

Tres propiedades de esta API guían cada decisión de abajo:

Funciones como load_confirmed_order, fulfil_order y reverse_fulfilment son código de tu propio dominio; aquí solo se detallan las partes que miran hacia ROKI.

1. Configuración: credenciales desde una tabla de ajustes

La llave secreta y el secreto de firma del webhook viven en una tabla settings, no en la imagen ni en el entorno, así que rotar una llave es un UPDATE más una invalidación de caché. El entorno sale del prefijo de la llave (sk_test_ sandbox, sk_live_ producción): la URL base es idéntica para ambos, así que el prefijo es lo único que decide dónde cae un cobro.

# app/roki/config.py
from __future__ import annotations

import asyncio
import time
from dataclasses import dataclass
from typing import Final

from sqlalchemy import String, Text, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Setting(Base):
    """Runtime configuration. Rotating a key is an UPDATE, never a redeploy."""

    __tablename__ = "settings"

    key: Mapped[str] = mapped_column(String(64), primary_key=True)
    value: Mapped[str] = mapped_column(Text, nullable=False)


SECRET_KEY_SETTING: Final[str] = "roki.secret_key"
SIGNING_SECRET_SETTING: Final[str] = "roki.webhook_signing_secret"
CACHE_TTL_SECONDS: Final[float] = 30.0


class MissingSettingError(RuntimeError):
    """A required ROKI setting is absent or malformed."""


@dataclass(frozen=True, slots=True)
class RokiCredentials:
    secret_key: str
    webhook_signing_secret: str

    @property
    def is_sandbox(self) -> bool:
        return self.secret_key.startswith("sk_test_")

    def __repr__(self) -> str:
        # Never let a key reach a log line or a traceback.
        return f"RokiCredentials(env={'sandbox' if self.is_sandbox else 'live'})"


class CredentialStore:
    """Reads ROKI credentials from the settings table behind a short TTL cache."""

    def __init__(
        self,
        session_factory: async_sessionmaker[AsyncSession],
        ttl_seconds: float = CACHE_TTL_SECONDS,
    ) -> None:
        self._session_factory = session_factory
        self._ttl = ttl_seconds
        self._lock = asyncio.Lock()
        self._cached: RokiCredentials | None = None
        self._expires_at: float = 0.0

    async def get(self) -> RokiCredentials:
        cached = self._cached
        if cached is not None and time.monotonic() < self._expires_at:
            return cached
        async with self._lock:
            if self._cached is not None and time.monotonic() < self._expires_at:
                return self._cached
            credentials = await self._load()
            self._cached = credentials
            self._expires_at = time.monotonic() + self._ttl
            return credentials

    def invalidate(self) -> None:
        """Call from the admin endpoint that writes a new key, for instant rotation."""
        self._cached = None
        self._expires_at = 0.0

    async def _load(self) -> RokiCredentials:
        wanted = (SECRET_KEY_SETTING, SIGNING_SECRET_SETTING)
        async with self._session_factory() as session:
            result = await session.execute(select(Setting).where(Setting.key.in_(wanted)))
            values: dict[str, str] = {row.key: row.value for row in result.scalars()}

        try:
            secret_key = values[SECRET_KEY_SETTING].strip()
            signing_secret = values[SIGNING_SECRET_SETTING].strip()
        except KeyError as exc:
            raise MissingSettingError(f"Setting {exc.args[0]!r} is not configured") from exc

        if not secret_key.startswith(("sk_test_", "sk_live_")):
            raise MissingSettingError("roki.secret_key must start with sk_test_ or sk_live_")
        if not signing_secret:
            raise MissingSettingError("roki.webhook_signing_secret is empty")
        return RokiCredentials(secret_key=secret_key, webhook_signing_secret=signing_secret)

La URL del webhook y su secreto de firma se registran por entorno en el portal del comercio. Actualizá ambas filas en una sola transacción cuando movés un comercio entre sandbox y producción, o los webhooks verificados empiezan a rebotar contra un secreto viejo.

2. El cliente de la API

Este cliente cubre solo lo que necesita el checkout del modo 1: createPayment (POST /payments) y getPayment (GET /payments/{id}). La API además sirve el listado (9.2), la anulación, el reembolso y los recibos (13), y las tarjetas guardadas (23); quedan afuera acá para que el ejemplo siga siendo sobre el checkout, no porque no estén disponibles.

Modelos de petición y respuesta

extra="forbid" en la petición es el único lugar donde se puede atrapar un error de tipeo - la API no lo va a hacer por vos. extra="allow" en la respuesta mantiene el cliente funcionando cuando ROKI agrega un campo.

# app/roki/models.py
from __future__ import annotations

from decimal import ROUND_HALF_UP, Decimal
from enum import StrEnum
from typing import Any, Final, Literal, Self

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    field_serializer,
    field_validator,
    model_validator,
)

CURRENCY_HNL: Final[str] = "340"  # ISO 4217 numeric code for the Honduran lempira
MAX_AMOUNT: Final[Decimal] = Decimal("250000.00")  # merchant policy: the API enforces no ceiling
CENT: Final[Decimal] = Decimal("0.01")


class PaymentStatus(StrEnum):
    PENDING = "pending"
    PAID = "paid"
    PARTIALLY_REFUNDED = "partially_refunded"
    REFUNDED = "refunded"
    VOIDED = "voided"
    EXPIRED = "expired"
    DISABLED = "disabled"


#: The only status that can still turn into a payment on its own. Everything else is settled as far
#: as checkout is concerned; later refunds and voids arrive as webhooks.
OPEN_STATUSES: Final[frozenset[str]] = frozenset({PaymentStatus.PENDING.value})


class PaymentCreateRequest(BaseModel):
    """Body of POST /payments.

    `extra="forbid"` mirrors `additionalProperties: false` in the spec. The live API answers 201
    and drops any field it does not recognise - naming it in `warnings`, never erroring - so a typo
    such as `service_fee` instead of `service_fee_enabled` yields a payment with no fee
    pass-through. Failing here means the request never leaves the process.
    """

    model_config = ConfigDict(extra="forbid", frozen=True)

    # Required.
    amount: Decimal = Field(gt=Decimal("0"), le=MAX_AMOUNT)
    external_reference: str = Field(min_length=1, max_length=191)
    name: str = Field(min_length=1, max_length=191)

    # Optional.
    currency_code: str | None = Field(default=None, pattern=r"^[0-9]{3}$")
    description: str | None = Field(default=None, max_length=120)
    metadata: dict[str, str] | None = None
    success_url: str | None = None
    cancel_url: str | None = None
    expires_at: str | None = None

    sales_tax_type: Literal["none", "fixed", "percentage"] | None = None
    sales_tax_value: Decimal | None = Field(default=None, ge=Decimal("0"))

    tip_enabled: bool | None = None
    tip_type: Literal["fixed", "percentage"] | None = None
    tip_value: Decimal | None = Field(default=None, ge=Decimal("0"))
    tip_customer_selectable: bool | None = None
    tip_preset_percentages: list[float] | None = None
    tip_allow_custom: bool | None = None
    tip_min_amount: Decimal | None = Field(default=None, ge=Decimal("0"))
    tip_max_amount: Decimal | None = Field(default=None, ge=Decimal("0"))

    service_fee_enabled: bool | None = None
    reusable: bool | None = None

    @field_validator("amount", "sales_tax_value", "tip_value", "tip_min_amount", "tip_max_amount")
    @classmethod
    def _quantize(cls, value: Decimal | None) -> Decimal | None:
        if value is None:
            return None
        return value.quantize(CENT, rounding=ROUND_HALF_UP)

    @field_serializer(
        "amount",
        "sales_tax_value",
        "tip_value",
        "tip_min_amount",
        "tip_max_amount",
        when_used="unless-none",
    )
    def _money_on_the_wire(self, value: Decimal) -> float:
        # Amounts are DECIMAL UNITS, never cents: 150.50 means L 150.50. Money stays Decimal
        # everywhere in the codebase and becomes a JSON number only here; a two-decimal value of
        # this magnitude round-trips through a float exactly.
        return float(value)

    @model_validator(mode="after")
    def _business_rules(self) -> Self:
        # Rules the API answers with a 422. Checking them locally saves a round trip and turns a
        # localized message into a typed failure.
        if self.sales_tax_type in ("fixed", "percentage") and self.sales_tax_value is None:
            raise ValueError("sales_tax_value is required when sales_tax_type is not 'none'")
        if self.sales_tax_type == "percentage" and (self.sales_tax_value or Decimal("0")) > 100:
            raise ValueError("sales_tax_value cannot exceed 100 for a percentage tax")
        if self.tip_enabled:
            fixed_tip = self.tip_type is not None and self.tip_value is not None
            if not fixed_tip and not self.tip_customer_selectable:
                raise ValueError(
                    "tip_enabled requires tip_type + tip_value, or tip_customer_selectable"
                )
        if self.tip_max_amount is not None and self.tip_max_amount > self.amount:
            raise ValueError("tip_max_amount cannot exceed amount")
        return self

    @property
    def has_fixed_tip(self) -> bool:
        return bool(self.tip_enabled) and self.tip_type is not None


class Payment(BaseModel):
    """The payment object, returned identically by createPayment and getPayment."""

    model_config = ConfigDict(extra="allow")

    id: int
    status: str  # kept as str so an unknown future status parses instead of exploding
    name: str
    description: str | None = None
    amount: Decimal
    reusable: bool = False
    subtotal: Decimal
    sales_tax_amount: Decimal = Decimal("0")
    service_fee_amount: Decimal = Decimal("0")
    total: Decimal
    currency: str
    currency_iso: str
    external_reference: str
    # The API returns an empty array, not an empty object, when no metadata was sent.
    metadata: dict[str, Any] | list[Any] = Field(default_factory=dict)
    checkout_url: str
    transaction_id: int | None = None
    expires_at: str | None = None
    created_at: str
    paid_at: str | None = None
    # Present only on payments with a void or refund history.
    refunded_amount: Decimal | None = None
    refund_status: Literal["none", "partial", "full"] | None = None

    @property
    def meta(self) -> dict[str, Any]:
        return self.metadata if isinstance(self.metadata, dict) else {}

    @property
    def is_open(self) -> bool:
        return self.status in OPEN_STATUSES

Errores

# app/roki/errors.py
from __future__ import annotations

from typing import Mapping


class RokiError(Exception):
    def __init__(self, message: str, *, status_code: int | None = None) -> None:
        super().__init__(message)
        self.message = message
        self.status_code = status_code


class RokiAuthError(RokiError):
    """401: missing Authorization header, or invalid key. Never retry; fix the settings row."""


class RokiPaymentNotFoundError(RokiError):
    """404 `payment_not_found`: the route exists, no payment with that id for THIS key.

    In practice this nearly always means the key was rotated to the other environment. A payment
    created with sk_live_ is invisible to sk_test_ and vice versa.
    """


class RokiTransactionIdExpectedError(RokiError):
    """404 `transaction_id_expected`: void, refund or a receipt got the numeric payment id.

    Those routes are keyed by the `transaction_id` UUID from the payment response. The endpoint
    exists and answered; only the kind of identifier was wrong. Fix the argument, not the URL.
    """


class RokiRoutingError(RokiError):
    """404 `not_found`: the PATH does not exist.

    A wrong base URL, a missing `/v1`, or a call to an endpoint this API does not have. Seeing it in
    production means code was written against a route that is not there; no identifier fixes it.
    This is the only error whose message stays English regardless of `Accept-Language`.
    """


class RokiValidationError(RokiError):
    """422: validation or business-rule failure, with per-field detail."""

    def __init__(
        self,
        message: str,
        *,
        errors: Mapping[str, list[str]],
        status_code: int | None = 422,
    ) -> None:
        super().__init__(message, status_code=status_code)
        # Message text is localized by Accept-Language; the KEYS are stable. Branch on the keys.
        self.errors: Mapping[str, list[str]] = errors

    @property
    def fields(self) -> list[str]:
        return sorted(self.errors)

    def first_for(self, field: str) -> str | None:
        messages = self.errors.get(field)
        return messages[0] if messages else None


class RokiServerError(RokiError):
    """5xx. Not part of the documented contract; treated as retryable."""


class RokiTransportError(RokiError):
    """Timeout, DNS failure, connection reset. Retryable."""


class RokiUnexpectedStatusError(RokiError):
    """Anything else the API returned that this client does not model."""

El cliente

Los timeouts están separados para que una lectura lenta no pueda consumir el presupuesto de conexión. Los reintentos cubren solo fallas de transporte y 5xx, y son seguros precisamente por el Idempotency-Key: un reenvío devuelve el pago original en lugar de crear un segundo.

# app/roki/client.py
from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import re
from decimal import Decimal
from typing import Any, Final, Mapping, Sequence

import httpx

from app.roki.config import CredentialStore
from app.roki.errors import (
    RokiAuthError,
    RokiError,
    RokiPaymentNotFoundError,
    RokiRoutingError,
    RokiServerError,
    RokiTransactionIdExpectedError,
    RokiTransportError,
    RokiUnexpectedStatusError,
    RokiValidationError,
)
from app.roki.models import Payment, PaymentCreateRequest

logger = logging.getLogger(__name__)

BASE_URL: Final[str] = "https://aura.roki.systems/api/connect/v1"
TIMEOUT: Final[httpx.Timeout] = httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=5.0)
MAX_ATTEMPTS: Final[int] = 3
IDEMPOTENCY_KEY_MAX_LENGTH: Final[int] = 191

_UNSAFE_KEY_CHARS = re.compile(r"[^A-Za-z0-9_.-]")


def build_idempotency_key(request: PaymentCreateRequest) -> str:
    """Derive the Idempotency-Key from the ORDER CONTENT, not from the order id alone.

    Verified behaviour, and it differs from the industry standard: replaying a key with a DIFFERENT
    body returns the ORIGINAL payment, with no error. The key wins and the body is ignored. A plain
    `order-1001` key reused after the customer edited the cart would therefore charge the old
    amount, silently. Hashing the exact serialized body makes any change produce a different key,
    while a genuine retry of the same body reuses it.

    This only holds if the body is deterministic: never build it from `datetime.now()`.
    See `link_expiry()` in the checkout flow.
    """
    payload = request.model_dump(mode="json", exclude_none=True)
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
    digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    prefix = _UNSAFE_KEY_CHARS.sub("-", request.external_reference)[:96]
    return f"{prefix}-{digest[:48]}"[:IDEMPOTENCY_KEY_MAX_LENGTH]


def _decode(response: httpx.Response) -> Any:
    # parse_float=Decimal keeps 105.52 exact. Money must never pass through a binary float.
    return json.loads(response.text or "null", parse_float=Decimal)


def _classify(response: httpx.Response) -> RokiError:
    try:
        payload = _decode(response)
    except json.JSONDecodeError:
        payload = None
    message = payload.get("message", "") if isinstance(payload, dict) else ""
    # Every error carries a stable `code`. `message` is localized by Accept-Language, so it is not
    # safe to branch on - and for 404 it is not even sufficient any more (see below).
    code = payload.get("code", "") if isinstance(payload, dict) else ""
    status = response.status_code

    if status == 401:
        # Two distinct texts exist (header missing vs. invalid key). Both mean the same thing to the
        # caller: stop, and page an operator. The settings row is wrong, revoked, or from the other
        # environment.
        return RokiAuthError(message or "Unauthorized", status_code=status)

    if status == 404:
        # Three different 404s, with three different fixes. Separate them by `code`.
        #
        # Until 2026-08-21 this block matched the message for "could not be found", because passing
        # the numeric payment id to void/refund/receipt used to return the routing 404. It does not
        # any more: it returns `transaction_id_expected`. A regex on the message therefore lands the
        # most common identifier mistake in the PaymentNotFound branch, and the operator goes
        # looking for a bad id when the id was fine and its TYPE was wrong.
        if code == "not_found":
            return RokiRoutingError(message, status_code=status)
        if code == "transaction_id_expected":
            return RokiTransactionIdExpectedError(message, status_code=status)
        return RokiPaymentNotFoundError(message or "Payment not found", status_code=status)

    if status == 422:
        raw = payload.get("errors") if isinstance(payload, dict) else None
        errors: dict[str, list[str]] = {}
        if isinstance(raw, dict):
            for field, messages in raw.items():
                errors[str(field)] = (
                    [str(m) for m in messages] if isinstance(messages, list) else [str(messages)]
                )
        return RokiValidationError(message or "Validation failed", errors=errors)

    if status >= 500:
        return RokiServerError(f"HTTP {status}", status_code=status)
    return RokiUnexpectedStatusError(f"HTTP {status}: {message}", status_code=status)


class RokiClient:
    """Client for the checkout half of the API: create a payment, then read it back.

    void(), refund(), receipt() and list_payments() are deliberately absent from THIS client, which
    only covers checkout. Those routes do exist; the reversal ones are keyed by the transaction
    UUID, and calling them with the numeric payment id answers 404 `transaction_id_expected`.
    Voids and refunds also reach you as webhooks when performed in the portal.
    """

    def __init__(self, http: httpx.AsyncClient, credentials: CredentialStore) -> None:
        self._http = http
        self._credentials = credentials

    async def create_payment(
        self,
        request: PaymentCreateRequest,
        *,
        idempotency_key: str | None = None,
    ) -> Payment:
        """operationId createPayment - POST /payments."""
        body = request.model_dump(mode="json", exclude_none=True)
        key = idempotency_key or build_idempotency_key(request)
        # 201 is the documented success status. 200 is accepted defensively: the status returned by
        # an idempotent replay is not pinned down by the spec.
        payload = await self._request(
            "POST", "/payments", body=body, idempotency_key=key, expected=(201, 200)
        )
        return Payment.model_validate(payload)

    async def get_payment(self, payment_id: int) -> Payment:
        """operationId getPayment - GET /payments/{id}.

        This call, together with the webhook, is the source of truth. A customer landing on
        `success_url` is NOT confirmation: anyone can type that URL.
        """
        payload = await self._request("GET", f"/payments/{payment_id}", expected=(200,))
        return Payment.model_validate(payload)

    async def _request(
        self,
        method: str,
        path: str,
        *,
        expected: Sequence[int],
        body: Mapping[str, Any] | None = None,
        idempotency_key: str | None = None,
    ) -> Any:
        credentials = await self._credentials.get()
        headers: dict[str, str] = {
            "Authorization": f"Bearer {credentials.secret_key}",
            "Accept": "application/json",
            # English keeps log lines readable. The `errors` keys are stable in either language.
            "Accept-Language": "en",
        }
        if idempotency_key is not None:
            headers["Idempotency-Key"] = idempotency_key

        last_error: RokiError | None = None
        for attempt in range(1, MAX_ATTEMPTS + 1):
            try:
                response = await self._http.request(
                    method, path, json=body, headers=headers, timeout=TIMEOUT
                )
            except httpx.TimeoutException:
                # Safe to retry: the payment may well have been created, and the Idempotency-Key
                # guarantees the replay returns that same payment rather than a duplicate.
                last_error = RokiTransportError(f"{method} {path} timed out")
            except httpx.TransportError as exc:
                last_error = RokiTransportError(f"{method} {path} failed: {type(exc).__name__}")
            else:
                if response.status_code in expected:
                    return _decode(response)
                error = _classify(response)
                if isinstance(error, RokiServerError) and attempt < MAX_ATTEMPTS:
                    last_error = error
                else:
                    raise error

            logger.warning("roki call failed, attempt %s/%s: %s", attempt, MAX_ATTEMPTS, last_error)
            if attempt < MAX_ATTEMPTS:
                await asyncio.sleep(0.5 * 2 ** (attempt - 1))

        raise last_error if last_error is not None else RokiTransportError(f"{method} {path} failed")

Construilo una sola vez por proceso para que el pool de conexiones se comparta:

# app/deps.py
from __future__ import annotations

from fastapi import Request

from app.roki.client import RokiClient
from app.roki.config import CredentialStore


def get_roki_client(request: Request) -> RokiClient:
    return request.app.state.roki


def get_credential_store(request: Request) -> CredentialStore:
    return request.app.state.roki_credentials

Manejo de 401 / 404 / 422 en el punto de llamada

from app.roki.errors import (
    RokiAuthError,
    RokiPaymentNotFoundError,
    RokiRoutingError,
    RokiTransactionIdExpectedError,
    RokiValidationError,
)

try:
    payment = await client.create_payment(request)
except RokiValidationError as exc:
    # `errors` is a per-field map: {"amount": ["The amount field is required."], ...}.
    # Branch on the keys, which are stable; never on the message, which is localized.
    if "currency_code" in exc.errors:
        # Either the currency is not enabled on the terminal, or this is a sk_test_ key whose
        # sandbox is not provisioned for payment links. Both are configuration, not user input.
        logger.error("roki rejected the currency: %s", exc.first_for("currency_code"))
    if "amount" in exc.errors:
        logger.error("roki rejected the amount: %s", exc.first_for("amount"))
    logger.error("roki 422 on fields %s: %s", exc.fields, exc.message)
    raise
except RokiAuthError as exc:
    # The key in the settings table is missing, revoked or malformed. Do not retry.
    logger.critical("roki authentication failed: %s", exc.message)
    raise
except RokiRoutingError as exc:
    # The path does not exist. Fix the URL, not the data.
    logger.critical("roki route missing: %s", exc.message)
    raise
except RokiTransactionIdExpectedError as exc:
    # Only reachable from void/refund/receipt: the call passed `payment.id` where
    # `payment.transaction_id` belongs. The route is fine; the argument is not.
    logger.critical("roki wants the transaction UUID, not the numeric id: %s", exc.message)
    raise
except RokiPaymentNotFoundError as exc:
    # From getPayment only: no payment with that id for the key currently configured.
    logger.warning("roki payment not visible to the current key: %s", exc.message)
    raise

3. Flujo de checkout

Creá el pago cuando la orden se confirma, persistí antes de la llamada de red y otra vez después de ella, verificá los campos calculados y después redirigí.

Una fila por intento, no por orden. external_reference no es único, así que nada del lado de ROKI impide que una orden adquiera dos enlaces activos; la fila del intento más el Idempotency-Key derivado del contenido es lo que lo evita. La fila también guarda attempt_started_at, que alimenta expires_at y por lo tanto hace que el cuerpo de la petición sea idéntico byte a byte entre reintentos del mismo intento.

# app/checkout/models.py
from __future__ import annotations

from datetime import datetime
from decimal import Decimal
from typing import Final

from sqlalchemy import (
    BigInteger,
    DateTime,
    Integer,
    Numeric,
    String,
    Text,
    UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column

from app.roki.config import Base

#: Local-only state, never a ROKI status: the row exists but createPayment has not returned yet.
LOCAL_CREATING: Final[str] = "creating"


class PaymentAttempt(Base):
    """Local mirror of one ROKI payment. GET /payments can list them, but this table is
    what knows which ones are still in flight."""

    __tablename__ = "payment_attempts"
    __table_args__ = (UniqueConstraint("order_id", "attempt"),)

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    order_id: Mapped[int] = mapped_column(BigInteger, index=True)
    attempt: Mapped[int] = mapped_column(Integer)
    # Seed for expires_at. Stored so a replay rebuilds an identical body and an identical key.
    attempt_started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

    idempotency_key: Mapped[str | None] = mapped_column(String(191), nullable=True)
    payment_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
    external_reference: Mapped[str] = mapped_column(String(191), index=True)
    status: Mapped[str] = mapped_column(String(32), default=LOCAL_CREATING, index=True)

    checkout_url: Mapped[str | None] = mapped_column(Text, nullable=True)
    total: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True)
    currency_iso: Mapped[str | None] = mapped_column(String(3), nullable=True)
    transaction_id: Mapped[str | None] = mapped_column(String(36), nullable=True)  # UUID
    paid_at: Mapped[str | None] = mapped_column(String(32), nullable=True)
    expires_at: Mapped[str | None] = mapped_column(String(32), nullable=True)
    refunded_amount: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True)
    refund_status: Mapped[str | None] = mapped_column(String(16), nullable=True)

    # Reconciliation bookkeeping.
    last_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    next_poll_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, index=True
    )
    poll_attempts: Mapped[int] = mapped_column(Integer, default=0)
    needs_review: Mapped[bool] = mapped_column(default=False)

Construcción de una petición determinista

# app/checkout/service.py
from __future__ import annotations

from datetime import datetime, timedelta
from decimal import Decimal
from typing import Final
from zoneinfo import ZoneInfo

from app.checkout.models import PaymentAttempt
from app.roki.models import CURRENCY_HNL, PaymentCreateRequest

HONDURAS: Final[ZoneInfo] = ZoneInfo("America/Tegucigalpa")  # UTC-6, the zone expires_at is read in
LINK_TTL: Final[timedelta] = timedelta(hours=2)
PUBLIC_BASE_URL: Final[str] = "https://yoursite.com"
SALES_TAX_PERCENTAGE: Final[Decimal] = Decimal("15")


def link_expiry(started_at: datetime) -> str:
    """Expiry derived from the attempt's stored start time, never from now().

    A body containing now() changes on every call, which changes the Idempotency-Key, which defeats
    the duplicate protection exactly when it matters: on a retry. Format is the one the API takes,
    in Honduras time.
    """
    return (started_at.astimezone(HONDURAS) + LINK_TTL).strftime("%Y-%m-%d %H:%M:%S")


def build_payment_request(order: "Order", attempt: PaymentAttempt) -> PaymentCreateRequest:
    return PaymentCreateRequest(
        amount=order.subtotal,  # decimal units: 150.50 is L 150.50, never cents
        external_reference=attempt.external_reference,
        name=f"Order #{order.id}",
        description=(order.summary or None) and order.summary[:120],
        currency_code=CURRENCY_HNL,
        # metadata comes back untouched on retrieval and in every webhook, so it travels with
        # the payment wherever you read it. Keep every value a string.
        metadata={
            "order_id": str(order.id),
            "attempt": str(attempt.attempt),
            "customer_email": order.customer_email,
        },
        success_url=f"{PUBLIC_BASE_URL}/checkout/return?order_id={order.id}",
        cancel_url=f"{PUBLIC_BASE_URL}/cart",
        expires_at=link_expiry(attempt.attempt_started_at),
        sales_tax_type="percentage",
        sales_tax_value=SALES_TAX_PERCENTAGE,
        service_fee_enabled=True,  # exact name; service_fee / pass_fees_to_customer are ignored
        reusable=False,
    )

Como attempt y expires_at cambian los dos en un intento nuevo, un intento fresco después de un enlace vencido produce una llave distinta y por lo tanto un pago genuinamente nuevo - mientras que un reintento dentro del mismo intento repite la llave vieja y recibe de vuelta el mismo pago.

Verificar la respuesta

La defensa contra un campo ignorado es revisar la respuesta - tanto su arreglo warnings como sus campos calculados. Fijate en lo que está ausente acá: cualquier recálculo de service_fee_amount. Las tasas que hay detrás son configuración por comercio que puede cambiar; service_fee_amount y total de la respuesta son la única fuente correcta.

# app/checkout/verify.py
from __future__ import annotations

from decimal import Decimal
from urllib.parse import urlparse

from app.roki.models import CENT, Payment, PaymentCreateRequest, PaymentStatus


class PaymentVerificationError(RuntimeError):
    def __init__(self, payment_id: int, problems: list[str]) -> None:
        super().__init__(f"payment {payment_id} does not match the request: {'; '.join(problems)}")
        self.payment_id = payment_id
        self.problems = problems


def verify_created_payment(payment: Payment, request: PaymentCreateRequest) -> None:
    """Catch an ignored field before the customer ever sees a checkout page.

    The API answers 201 for a body it only partly understood, so an unverified response can mean a
    payment with no tax, no tip or no fee pass-through, and no error anywhere.
    """
    problems: list[str] = []

    if payment.status != PaymentStatus.PENDING.value:
        problems.append(f"status is {payment.status!r}, expected 'pending'")
    if payment.amount != request.amount:
        problems.append(f"amount echoed {payment.amount}, sent {request.amount}")
    if payment.subtotal != request.amount:
        problems.append(f"subtotal is {payment.subtotal}, expected {request.amount}")
    if payment.external_reference != request.external_reference:
        problems.append("external_reference was not echoed")
    if request.currency_code and payment.currency != request.currency_code:
        problems.append(f"currency is {payment.currency!r}, requested {request.currency_code!r}")
    if request.reusable is not None and payment.reusable != request.reusable:
        problems.append("reusable was ignored")

    wants_tax = request.sales_tax_type in ("fixed", "percentage")
    if wants_tax and payment.sales_tax_amount <= 0:
        problems.append("sales_tax_type/sales_tax_value ignored: sales_tax_amount is 0")
    if not wants_tax and payment.sales_tax_amount != 0:
        problems.append(f"unexpected sales_tax_amount {payment.sales_tax_amount}")

    if request.service_fee_enabled and payment.service_fee_amount <= 0:
        problems.append("service_fee_enabled ignored: service_fee_amount is 0")
    if not request.service_fee_enabled and payment.service_fee_amount != 0:
        problems.append(f"unexpected service_fee_amount {payment.service_fee_amount}")

    # total = subtotal + tax + service fee. A customer-selected tip is added later at checkout, and
    # a FIXED tip is folded into the total without a field of its own, so assert the identity only
    # when no fixed tip was requested.
    if not request.has_fixed_tip:
        expected_total = payment.subtotal + payment.sales_tax_amount + payment.service_fee_amount
        if abs(payment.total - expected_total) > CENT:
            problems.append(f"total {payment.total} != {expected_total} (subtotal + tax + fee)")

    if payment.total <= Decimal("0"):
        problems.append(f"total is {payment.total}")
    if urlparse(payment.checkout_url).scheme != "https":
        problems.append("checkout_url is not https")

    if problems:
        raise PaymentVerificationError(payment.id, problems)

El endpoint

# app/checkout/routes.py
from __future__ import annotations

import logging
from datetime import datetime, timedelta, timezone

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.checkout.models import LOCAL_CREATING, PaymentAttempt
from app.checkout.service import build_payment_request
from app.checkout.verify import PaymentVerificationError, verify_created_payment
from app.deps import get_roki_client, get_session
from app.orders.service import load_confirmed_order
from app.reconcile.state import apply_payment_state
from app.roki.client import RokiClient, build_idempotency_key
from app.roki.errors import RokiAuthError, RokiError, RokiValidationError
from app.roki.models import PaymentStatus

logger = logging.getLogger(__name__)
router = APIRouter()

FIRST_POLL_DELAY = timedelta(seconds=30)
SEE_OTHER = status.HTTP_303_SEE_OTHER


@router.post("/orders/{order_id}/checkout", status_code=SEE_OTHER)
async def start_checkout(
    order_id: int,
    session: AsyncSession = Depends(get_session),
    client: RokiClient = Depends(get_roki_client),
) -> RedirectResponse:
    order = await load_confirmed_order(session, order_id)

    latest = await session.scalar(
        select(PaymentAttempt)
        .where(PaymentAttempt.order_id == order_id)
        .order_by(PaymentAttempt.attempt.desc())
        .limit(1)
    )

    if latest is not None and latest.payment_id is not None:
        # Refresh before trusting a stored 'pending': the link may have expired, or been paid in a
        # tab we never heard about.
        current = await client.get_payment(latest.payment_id)
        apply_payment_state(latest, current)
        await session.commit()
        if current.is_open and latest.checkout_url:
            return RedirectResponse(latest.checkout_url, status_code=SEE_OTHER)
        if current.status != PaymentStatus.EXPIRED.value:
            raise HTTPException(409, f"Order {order_id} is already settled ({current.status})")
        latest = None  # expired: start a new attempt, which yields a new Idempotency-Key

    if latest is None:
        # New attempt. Committed BEFORE the network call so a crash mid-create leaves a row we can
        # replay: same attempt_started_at -> same body -> same key -> same payment.
        previous_count = await session.scalar(
            select(PaymentAttempt.attempt)
            .where(PaymentAttempt.order_id == order_id)
            .order_by(PaymentAttempt.attempt.desc())
            .limit(1)
        )
        latest = PaymentAttempt(
            order_id=order.id,
            attempt=(previous_count or 0) + 1,
            attempt_started_at=datetime.now(timezone.utc),
            external_reference=f"order-{order.id}",
            status=LOCAL_CREATING,
        )
        session.add(latest)
        await session.commit()

    request = build_payment_request(order, latest)
    idempotency_key = build_idempotency_key(request)
    latest.idempotency_key = idempotency_key
    await session.commit()

    try:
        payment = await client.create_payment(request, idempotency_key=idempotency_key)
    except RokiValidationError as exc:
        logger.error("roki rejected order %s on %s: %s", order_id, exc.fields, exc.message)
        raise HTTPException(502, "Payment could not be created") from exc
    except RokiAuthError as exc:
        logger.critical("roki credentials rejected: %s", exc.message)
        raise HTTPException(503, "Payment provider unavailable") from exc
    except RokiError as exc:
        logger.error("roki create failed for order %s: %s", order_id, exc)
        raise HTTPException(502, "Payment could not be created") from exc

    # Persist the id and status before verifying and before redirecting.
    latest.payment_id = payment.id
    latest.checkout_url = payment.checkout_url
    apply_payment_state(latest, payment)
    latest.next_poll_at = datetime.now(timezone.utc) + FIRST_POLL_DELAY
    await session.commit()

    try:
        verify_created_payment(payment, request)
    except PaymentVerificationError as exc:
        # Do NOT redirect: the customer would pay a total we did not intend. Void acts on a
        # transaction, and a link nobody has paid yet has none - transaction_id stays null until
        # the charge (9.1) - so at this instant there is nothing to void. Flag the row and let the
        # link expire unpaid. If someone reaches the URL anyway, `needs_review` is what the
        # payment.approved handler checks before fulfilling, and it stops there: reversing is
        # void/refund (13.2), which this client deliberately does not carry - wire it yourself.
        latest.needs_review = True
        await session.commit()
        logger.critical("roki payment %s failed verification: %s", payment.id, exc.problems)
        raise HTTPException(502, "Payment could not be created") from exc

    logger.info(
        "order %s -> roki payment %s, total %s %s",
        order.id, payment.id, payment.total, payment.currency_iso,
    )
    return RedirectResponse(payment.checkout_url, status_code=SEE_OTHER)

La página de retorno no es una confirmación

@router.get("/checkout/return")
async def checkout_return(
    order_id: int,
    session: AsyncSession = Depends(get_session),
    client: RokiClient = Depends(get_roki_client),
) -> dict[str, str]:
    """success_url landing. Anyone can open this URL, so confirm with getPayment before showing
    anything that resembles a receipt."""
    attempt = await session.scalar(
        select(PaymentAttempt)
        .where(PaymentAttempt.order_id == order_id, PaymentAttempt.payment_id.is_not(None))
        .order_by(PaymentAttempt.attempt.desc())
        .limit(1)
    )
    if attempt is None or attempt.payment_id is None:
        raise HTTPException(404, "Unknown order")

    payment = await client.get_payment(attempt.payment_id)
    apply_payment_state(attempt, payment)
    await session.commit()
    return {"status": payment.status, "total": str(payment.total)}

4. Manejador del webhook

El HMAC cubre los bytes exactos que ROKI envió. Leé el cuerpo crudo con await request.body() antes de cualquier parseo, y nunca lo re-serialices: un cuerpo recodificado difiere en el orden de las llaves, en los espacios o en el escapado unicode, y la firma no va a coincidir. Por la misma razón esta ruta recibe un Request pelado y no declara ningún parámetro de cuerpo de Pydantic - en el momento en que FastAPI parsea y revalida un modelo de cuerpo, los bytes exactos dejan de ser lo que estás verificando.

# app/webhooks/signature.py
from __future__ import annotations

import hashlib
import hmac
import re
import time
from typing import Final

_HEX = re.compile(r"^[0-9a-fA-F]+$")

#: Client-side replay window. The signature format is documented; a tolerance is not, and whether a
#: redelivery is re-signed with a fresh timestamp is unverified. Disabled by default so a legitimate
#: redelivery is never rejected; replay protection comes from the event-id dedupe below.
DEFAULT_TOLERANCE_SECONDS: Final[int | None] = None


def parse_signature_header(header: str) -> tuple[str, str] | None:
    """Parse `t={unix_timestamp},v1={hmac_sha256_hex}`."""
    parts: dict[str, str] = {}
    for chunk in header.split(","):
        name, separator, value = chunk.strip().partition("=")
        if separator:
            parts[name.strip()] = value.strip()
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        return None
    return timestamp, signature


def verify_signature(
    *,
    raw_body: bytes,
    header: str | None,
    secret: str,
    tolerance_seconds: int | None = DEFAULT_TOLERANCE_SECONDS,
) -> bool:
    """HMAC-SHA256(timestamp + "." + raw_body, signing_secret), compared in constant time."""
    if not header or not secret:
        return False
    parsed = parse_signature_header(header)
    if parsed is None:
        return False
    timestamp, received = parsed
    if not _HEX.match(received):
        # compare_digest raises on non-ASCII str input, so reject anything that is not hex first.
        return False
    if tolerance_seconds is not None:
        try:
            sent_at = int(timestamp)
        except ValueError:
            return False
        if abs(time.time() - sent_at) > tolerance_seconds:
            return False

    # The timestamp goes into the MAC exactly as it arrived: no int() round-trip, no reformatting.
    # Same for the body: the raw bytes, untouched.
    signed_payload = timestamp.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received.lower())
# app/webhooks/models.py
from __future__ import annotations

from datetime import datetime, timezone
from decimal import Decimal
from enum import StrEnum
from typing import Final

from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column

from app.roki.config import Base
from app.roki.models import Payment


class WebhookEventType(StrEnum):
    APPROVED = "payment.approved"
    FAILED = "payment.failed"
    EXPIRED = "payment.expired"
    VOIDED = "payment.voided"
    REFUNDED = "payment.refunded"
    PARTIALLY_REFUNDED = "payment.partially_refunded"


#: Emitted for every reversal, whether it came from POST /payments/{transaction_id}/void or
#: /refund, or from a human acting in the merchant portal. The name is about the case that catches
#: integrators out - a state change with no API call of yours behind it - not about the only origin.
PORTAL_ORIGINATED: Final[frozenset[str]] = frozenset(
    {
        WebhookEventType.VOIDED.value,
        WebhookEventType.REFUNDED.value,
        WebhookEventType.PARTIALLY_REFUNDED.value,
    }
)


class WebhookPaymentData(Payment):
    """The payment object, plus the three fields refund events carry on top of it."""

    refund_amount: Decimal | None = None
    refunded_at: str | None = None
    refund_reason: str | None = None


class WebhookEvent(BaseModel):
    model_config = ConfigDict(extra="allow")

    id: str  # unique per event; the dedupe key, because an event can be redelivered
    type: str
    created_at: str
    data: WebhookPaymentData


class ProcessedEvent(Base):
    __tablename__ = "roki_webhook_events"

    event_id: Mapped[str] = mapped_column(String(191), primary_key=True)
    event_type: Mapped[str] = mapped_column(String(64))
    payment_id: Mapped[int] = mapped_column(BigInteger, index=True)
    received_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
    )
# app/webhooks/routes.py
from __future__ import annotations

import logging

from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession

from app.deps import get_credential_store, get_session
from app.roki.config import CredentialStore
from app.webhooks.models import ProcessedEvent, WebhookEvent
from app.webhooks.processing import process_event
from app.webhooks.signature import verify_signature

logger = logging.getLogger(__name__)
router = APIRouter()


@router.post("/webhooks/roki", include_in_schema=False)
async def roki_webhook(
    request: Request,
    background: BackgroundTasks,
    session: AsyncSession = Depends(get_session),
    credentials: CredentialStore = Depends(get_credential_store),
) -> Response:
    # 1. RAW BYTES FIRST. The HMAC covers exactly these bytes; parsing and re-encoding breaks it.
    raw_body: bytes = await request.body()
    signature_header = request.headers.get("ROKI-Signature")

    # 2. Verify before trusting a single byte of the payload.
    secret = (await credentials.get()).webhook_signing_secret
    if not verify_signature(raw_body=raw_body, header=signature_header, secret=secret):
        logger.warning("rejected roki webhook: invalid ROKI-Signature")
        return JSONResponse({"detail": "invalid signature"}, status_code=400)

    # 3. Only now parse.
    try:
        event = WebhookEvent.model_validate_json(raw_body)
    except ValidationError as exc:
        logger.error("signed roki webhook failed to parse: %s errors", exc.error_count())
        return JSONResponse({"detail": "malformed event"}, status_code=400)

    # 4. Dedupe on the event id, committed before the 200 goes out, so a redelivery racing this
    #    request cannot start a second processing run.
    statement = (
        insert(ProcessedEvent)
        .values(event_id=event.id, event_type=event.type, payment_id=event.data.id)
        .on_conflict_do_nothing(index_elements=["event_id"])
    )
    result = await session.execute(statement)
    await session.commit()

    if result.rowcount == 0:
        logger.info("roki event %s already seen; ignoring redelivery", event.id)
        return Response(status_code=200)

    # 5. Answer fast, do the work afterwards.
    background.add_task(process_event, event)
    return Response(status_code=200)

on_conflict_do_nothing es del dialecto de PostgreSQL. En MySQL usá sqlalchemy.dialects.mysql.insert(...).prefix_with("IGNORE"); en SQLite, el dialecto sqlite tiene el mismo on_conflict_do_nothing. Lo que importa es que el insert haga commit antes del 200 y que rowcount te diga si esta entrega es la primera.

# app/webhooks/processing.py
from __future__ import annotations

import logging
from datetime import datetime
from zoneinfo import ZoneInfo

from sqlalchemy import select

from app.checkout.models import PaymentAttempt
from app.db import session_factory
from app.orders.service import (
    close_abandoned_checkout,
    fulfil_order,
    note_failed_attempt,
    reverse_fulfilment,
)
from app.reconcile.state import apply_payment_state
from app.webhooks.models import PORTAL_ORIGINATED, WebhookEvent, WebhookEventType

logger = logging.getLogger(__name__)
HONDURAS = ZoneInfo("America/Tegucigalpa")


def parse_api_timestamp(value: str | None) -> datetime | None:
    """API timestamps arrive as 'YYYY-MM-DD HH:MM:SS'.

    The spec pins expires_at to Honduras time (UTC-6); the others arrive in the same naive shape and
    are read the same way. The original strings are stored verbatim too, so nothing is lost if that
    assumption ever needs revisiting.
    """
    if not value:
        return None
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError:
        return None
    return parsed.replace(tzinfo=HONDURAS) if parsed.tzinfo is None else parsed


async def process_event(event: WebhookEvent) -> None:
    """Runs after the 200 has been sent, with its own session: the request's is already closed."""
    async with session_factory() as session:
        attempt = await session.scalar(
            select(PaymentAttempt).where(PaymentAttempt.payment_id == event.data.id)
        )
        if attempt is None:
            # A payment created by another system, or a row we lost. Do not guess: alert.
            logger.error(
                "roki event %s references unknown payment %s (external_reference=%s)",
                event.id, event.data.id, event.data.external_reference,
            )
            return

        applied = apply_payment_state(
            attempt, event.data, event_at=parse_api_timestamp(event.created_at)
        )
        if not applied:
            await session.commit()
            return

        # The business action comes from the event type; the payment state comes from data.status.
        if event.type == WebhookEventType.APPROVED.value:
            if attempt.needs_review:
                # Flagged at creation because the totals did not match what we asked for, and paid
                # anyway before the link expired. Fulfilling would ship goods against a charge we
                # never agreed to, so this stops and escalates instead of shipping.
                #
                # It stops, it does not reverse: void and refund (13.2) are real endpoints but this
                # client deliberately does not carry them, and a comment promising a reversal that
                # no line here performs is the exact failure this guide is written against. Reverse
                # it yourself - void while the transaction is unsettled, refund once it is not.
                logger.critical(
                    "roki payment %s paid while flagged for review - reverse, do not fulfil",
                    attempt.payment_id,
                )
            else:
                await fulfil_order(session, attempt.order_id, payment=event.data)
        elif event.type == WebhookEventType.FAILED.value:
            # The card was declined. The link is not necessarily dead: the customer can try again
            # until it expires, so leave the order awaiting payment.
            await note_failed_attempt(session, attempt.order_id)
        elif event.type == WebhookEventType.EXPIRED.value:
            await close_abandoned_checkout(session, attempt.order_id)
        elif event.type in PORTAL_ORIGINATED:
            # Void, full refund or partial refund, normally initiated by a human in the portal.
            await reverse_fulfilment(
                session,
                attempt.order_id,
                refund_amount=event.data.refund_amount,
                refund_reason=event.data.refund_reason,
                refunded_at=event.data.refunded_at,
                refund_status=event.data.refund_status,
            )
        else:
            logger.warning("unhandled roki event type %r", event.type)

        await session.commit()

Un solo lugar aplica localmente un estado de pago de ROKI, compartido por el webhook y el proceso de consulta periódica:

# app/reconcile/state.py
from __future__ import annotations

import logging
from datetime import datetime

from app.checkout.models import PaymentAttempt
from app.roki.models import OPEN_STATUSES, Payment

logger = logging.getLogger(__name__)


def apply_payment_state(
    attempt: PaymentAttempt,
    payment: Payment,
    *,
    event_at: datetime | None = None,
) -> bool:
    """Copy the authoritative payment state onto the local row. Returns False if ignored as stale."""
    if event_at is not None and attempt.last_event_at is not None and event_at < attempt.last_event_at:
        # Events can arrive out of order. Never let a late payment.approved overwrite a refund.
        logger.info("ignoring stale roki event for payment %s", payment.id)
        return False

    attempt.status = payment.status
    attempt.total = payment.total
    attempt.currency_iso = payment.currency_iso
    attempt.transaction_id = payment.transaction_id
    attempt.paid_at = payment.paid_at
    attempt.expires_at = payment.expires_at
    attempt.refunded_amount = payment.refunded_amount
    attempt.refund_status = payment.refund_status
    if event_at is not None:
        attempt.last_event_at = event_at
    if payment.status not in OPEN_STATUSES:
        attempt.next_poll_at = None  # settled: stop the polling fallback
    return True

Para una orden de alto valor, releer el pago antes de despachar cuesta una llamada y elimina cualquier duda sobre lo que realmente se le cobró al cliente:

if event.type == WebhookEventType.APPROVED.value:
    confirmed = await client.get_payment(event.data.id)
    if confirmed.status != PaymentStatus.PAID.value:
        logger.error("payment %s reported approved but reads %s", confirmed.id, confirmed.status)
        return
    await fulfil_order(session, attempt.order_id, payment=confirmed)

5. Respaldo con consulta periódica

Los webhooks se pierden: un despliegue reinicia el proceso a media petición, un proxy se queda sin tiempo, el DNS falla un momento, o una firma se rechaza porque un secreto se rotó en el orden equivocado. Por eso cada pago que queda en pending lleva un next_poll_at, y un barredor recorre los que ya toca revisar con un backoff que se va ampliando. Llama a getPayment y a nada más: una petición por cada pago que de verdad estás esperando le gana a paginar GET /payments para las mismas respuestas. El endpoint de listado (9.2) cubre el otro caso, el que este barredor estructuralmente deja afuera: un pago que existe en ROKI y que acá nunca llegó a tener fila.

# app/reconcile/poller.py
from __future__ import annotations

import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Final

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from app.checkout.models import PaymentAttempt
from app.orders.service import fulfil_order
from app.reconcile.state import apply_payment_state
from app.roki.client import RokiClient
from app.roki.errors import RokiAuthError, RokiPaymentNotFoundError, RokiTransportError
from app.roki.models import OPEN_STATUSES, PaymentStatus

logger = logging.getLogger(__name__)

#: Seconds to wait after each attempt: dense while the customer is still on the payment page,
#: sparse afterwards.
BACKOFF_SECONDS: Final[tuple[int, ...]] = (15, 30, 60, 120, 300, 600, 900, 1800, 3600)
GIVE_UP_AFTER: Final[timedelta] = timedelta(hours=26)  # past any 2h link expiry, with slack
SWEEP_INTERVAL_SECONDS: Final[float] = 10.0
BATCH_SIZE: Final[int] = 50


def _next_delay(attempts: int) -> timedelta:
    return timedelta(seconds=BACKOFF_SECONDS[min(attempts, len(BACKOFF_SECONDS) - 1)])


async def poll_attempt(session: AsyncSession, client: RokiClient, row: PaymentAttempt) -> None:
    if row.payment_id is None:
        return
    now = datetime.now(timezone.utc)
    try:
        payment = await client.get_payment(row.payment_id)
    except RokiTransportError as exc:
        logger.warning("poll of payment %s failed: %s", row.payment_id, exc)
        row.poll_attempts += 1
        row.next_poll_at = now + _next_delay(row.poll_attempts)
        return
    except RokiAuthError as exc:
        # Do not burn the whole batch against a bad key: back off hard and alert.
        logger.critical("polling paused, roki credentials rejected: %s", exc.message)
        row.next_poll_at = now + timedelta(minutes=15)
        return
    except RokiPaymentNotFoundError:
        # The route exists but this id is invisible to the current key, which in practice means the
        # key now belongs to the other environment (sk_test_ vs sk_live_). Polling cannot recover.
        logger.critical(
            "payment %s not visible to the configured key; check the environment", row.payment_id
        )
        row.needs_review = True
        row.next_poll_at = None
        return

    was_open = row.status in OPEN_STATUSES
    apply_payment_state(row, payment)

    if payment.status == PaymentStatus.PAID.value and was_open:
        # The webhook never landed. fulfil_order must be idempotent anyway: this can also race a
        # webhook that is being processed right now.
        logger.warning("payment %s settled via polling; no webhook seen", payment.id)
        await fulfil_order(session, row.order_id, payment=payment)
        return

    if payment.status in OPEN_STATUSES:
        row.poll_attempts += 1
        row.next_poll_at = now + _next_delay(row.poll_attempts)


async def sweep_once(session_factory: async_sessionmaker[AsyncSession], client: RokiClient) -> None:
    now = datetime.now(timezone.utc)
    async with session_factory() as session:
        due = await session.scalars(
            select(PaymentAttempt)
            .where(
                PaymentAttempt.status.in_(OPEN_STATUSES),
                PaymentAttempt.payment_id.is_not(None),
                PaymentAttempt.next_poll_at.is_not(None),
                PaymentAttempt.next_poll_at <= now,
            )
            .order_by(PaymentAttempt.next_poll_at)
            .limit(BATCH_SIZE)
            .with_for_update(skip_locked=True)  # safe with more than one sweeper running
        )
        for row in due:
            if now - row.attempt_started_at > GIVE_UP_AFTER:
                # Long past expiry and still pending: stop spending calls, hand it to a human.
                row.next_poll_at = None
                row.needs_review = True
                continue
            await poll_attempt(session, client, row)
        await session.commit()


async def run_poller(
    session_factory: async_sessionmaker[AsyncSession],
    client: RokiClient,
    stop: asyncio.Event,
) -> None:
    while not stop.is_set():
        try:
            await sweep_once(session_factory, client)
        except Exception:  # a failed sweep must never kill the loop
            logger.exception("roki poll sweep failed")
        try:
            await asyncio.wait_for(stop.wait(), timeout=SWEEP_INTERVAL_SECONDS)
        except asyncio.TimeoutError:
            continue

Arrancalo desde el lifespan junto con el cliente HTTP compartido. Cada proceso worker corre su propia copia: with_for_update(skip_locked=True) mantiene eso correcto, pero un worker dedicado o una cola de trabajos (arq, Celery beat) es más limpio que N workers web barriendo todos a la vez.

# app/main.py
from __future__ import annotations

import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI

from app.checkout.routes import router as checkout_router
from app.db import session_factory
from app.reconcile.poller import run_poller
from app.roki.client import BASE_URL, RokiClient
from app.roki.config import CredentialStore
from app.webhooks.routes import router as webhook_router


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    stop = asyncio.Event()
    credentials = CredentialStore(session_factory)
    async with httpx.AsyncClient(
        base_url=BASE_URL,
        limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
        headers={"User-Agent": "acme-store/1.0 (+https://yoursite.com)"},
    ) as http:
        client = RokiClient(http, credentials)
        app.state.roki = client
        app.state.roki_credentials = credentials
        poller = asyncio.create_task(run_poller(session_factory, client, stop))
        try:
            yield
        finally:
            stop.set()
            await poller


app = FastAPI(lifespan=lifespan)
app.include_router(checkout_router)
app.include_router(webhook_router)

PHP 7.4+ puro con cURL (sin framework, sin Composer)

Objetivo: PHP 7.4 o más nuevo con las extensiones curl, json y pdo_mysql. Sin Composer, sin librerías externas - solo cURL y funciones nativas de PHP. Cada archivo de abajo es autónomo y se puede meter en un sitio hecho a mano o en un CMS viejo.

Los montos son unidades decimales (150.50 = L 150.50), nunca centavos. La moneda es HNL, ISO numérico "340".

Endpoints usados acá abajo - los dos que necesita el checkout del modo 1, no la API entera:

Llamada Ruta
Crear un pago POST https://aura.roki.systems/api/connect/v1/payments
Obtener un pago GET https://aura.roki.systems/api/connect/v1/payments/{id}

La anulación, el reembolso y los recibos se identifican con el UUID de la transacción. El listado vive en GET /payments. Las reversiones también las hace una persona en el portal del comercio, y tu integración se entera de ellas por medio de los webhooks.


1. Configuración: las llaves en una tabla de configuración, nunca en el código

Dejar sk_live_... fijo dentro de un archivo PHP significa que una rotación de llave necesita un redespliegue, y que la llave termina en los respaldos, en el administrador de archivos del CMS y, tarde o temprano, en un repositorio público. Guardá los dos secretos en una tabla de configuración para que rotarlos sea un solo UPDATE.

CREATE TABLE roki_settings (
  name       VARCHAR(64)  NOT NULL PRIMARY KEY,
  value      TEXT         NOT NULL,
  updated_at DATETIME     NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO roki_settings (name, value, updated_at) VALUES
  ('roki_secret_key',     'sk_test_replace_me', NOW()),
  ('roki_webhook_secret', 'replace_me',         NOW());
<?php
// roki/RokiConfig.php
declare(strict_types=1);

/**
 * Runtime configuration for ROKI Connect.
 *
 * The base URL is a constant on purpose: there is one server, and the environment is selected
 * exclusively by the key prefix (sk_test_ = sandbox, sk_live_ = production) over identical routes.
 * Only the secrets are runtime-configurable.
 */
final class RokiConfig
{
    const BASE_URL = 'https://aura.roki.systems/api/connect/v1';

    /** @var PDO */
    private $db;
    /** @var array<string,string> */
    private $cache = array();

    public function __construct(PDO $db)
    {
        $this->db = $db;
    }

    /** Merchant secret key. Server-side only: never render it in a page or a mobile app. */
    public function secretKey()
    {
        return $this->get('roki_secret_key');
    }

    /** Webhook signing secret. Separate value per environment - rotate it together with the key. */
    public function webhookSecret()
    {
        return $this->get('roki_webhook_secret');
    }

    /** 'live' or 'test', derived from the key prefix. Used for logging and for sanity checks. */
    public function environment()
    {
        return strpos($this->secretKey(), 'sk_live_') === 0 ? 'live' : 'test';
    }

    private function get($name)
    {
        if (isset($this->cache[$name])) {
            return $this->cache[$name];
        }
        $stmt = $this->db->prepare('SELECT value FROM roki_settings WHERE name = ?');
        $stmt->execute(array($name));
        $value = $stmt->fetchColumn();
        if ($value === false || trim((string) $value) === '') {
            throw new RuntimeException("ROKI setting '" . $name . "' is missing or empty.");
        }
        $this->cache[$name] = trim((string) $value);

        return $this->cache[$name];
    }
}
<?php
// roki/bootstrap.php - shared wiring, included by every entry point below.
declare(strict_types=1);

require __DIR__ . '/RokiConfig.php';
require __DIR__ . '/RokiApiException.php';
require __DIR__ . '/RokiClient.php';
require __DIR__ . '/payments.php';

function roki_db()
{
    static $db = null;
    if ($db === null) {
        $db = new PDO(
            'mysql:host=localhost;dbname=shop;charset=utf8mb4',
            'shop_user',
            getenv('SHOP_DB_PASSWORD'),
            array(
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            )
        );
    }

    return $db;
}

function roki_client()
{
    static $client = null;
    if ($client === null) {
        // Accept-Language 'en' keeps validation messages in English for your logs.
        $client = new RokiClient(new RokiConfig(roki_db()), 'en');
    }

    return $client;
}

/** Never let a secret reach a log file. */
function roki_log($message, array $context = array())
{
    $line = '[roki] ' . $message;
    if ($context) {
        $line .= ' ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }
    error_log(preg_replace('/sk_(test|live)_[A-Za-z0-9]+/', 'sk_$1_[redacted]', $line));
}

Procedimiento de rotación: UPDATE roki_settings SET value = ?, updated_at = NOW() WHERE name = ?. La siguiente petición ya la toma. Si cambiás entre sk_test_ y sk_live_, tenés que actualizar roki_webhook_secret en la misma transacción - el secreto de firma y la URL del webhook son por ambiente, y una llave de producción con un secreto de firma de sandbox rechaza todos los eventos con un 400.


2. El cliente de la API

Tres cosas que este cliente hace bien y que la mayoría de los escritos a mano no:

<?php
// roki/RokiApiException.php
declare(strict_types=1);

final class RokiApiException extends RuntimeException
{
    /** @var int HTTP status; 0 for a transport-level failure. */
    private $status;
    /** @var string The API `code`: stable, not localized. This is what you branch on. */
    private $code;
    /** @var array<string,string[]> Per-field errors from a 422. */
    private $errors;
    /** @var string Raw response body, for logging. */
    private $body;

    public function __construct($message, $status = 0, array $errors = array(), $body = '', $code = '')
    {
        parent::__construct($message, $status);
        $this->status = (int) $status;
        $this->errors = $errors;
        $this->body   = (string) $body;
        $this->code   = (string) $code;
    }

    public function status()
    {
        return $this->status;
    }

    /** The API error `code`, e.g. not_found, transaction_id_expected, payment_not_found. */
    public function code()
    {
        return $this->code;
    }

    /** @return array<string,string[]> Field name => list of messages. Empty unless status is 422. */
    public function errors()
    {
        return $this->errors;
    }

    public function body()
    {
        return $this->body;
    }

    /** First message recorded against a specific request field, or null. */
    public function errorFor($field)
    {
        return isset($this->errors[$field][0]) ? $this->errors[$field][0] : null;
    }

    /**
     * True when the 404 means "this path does not exist in the API".
     * A routing 404 on /payments/{id} means your base URL is wrong - no identifier fixes it.
     *
     * Branch on the `code`, not on the text. Until 2026-08-21 this method matched the message for
     * 'could not be found', because passing the numeric payment id to void/refund/receipt also
     * produced the routing 404. It no longer does - that case is transaction_id_expected now - so
     * the old text test reported "your URL is wrong" for a URL that was perfectly fine.
     */
    public function isRoutingError()
    {
        return $this->status === 404 && $this->code === 'not_found';
    }

    /**
     * True when a transaction route was handed the numeric payment id instead of the
     * transaction_id UUID. The endpoint exists and rejected the identifier: pass
     * $payment['transaction_id'], not $payment['id'].
     */
    public function isTransactionIdExpected()
    {
        return $this->status === 404 && $this->code === 'transaction_id_expected';
    }

    /** True when the payment id does not exist for the key in use (wrong id, or wrong environment). */
    public function isPaymentNotFound()
    {
        // Not "any 404 that is not a routing error": transaction_id_expected is a third case, and
        // lumping it in here is what sends people hunting for a bad id when the id was fine.
        return $this->status === 404 && !$this->isRoutingError() && !$this->isTransactionIdExpected();
    }
}
<?php
// roki/RokiClient.php
declare(strict_types=1);

final class RokiClient
{
    /** Whole-request timeout, seconds. A checkout must not hang a PHP worker. */
    const TIMEOUT = 20;
    /** TCP/TLS connect timeout, seconds. */
    const CONNECT_TIMEOUT = 5;
    /** Attempts for transport failures and 5xx. Safe for POST because of the Idempotency-Key. */
    const MAX_ATTEMPTS = 3;

    /** @var RokiConfig */
    private $config;
    /** @var string 'es' or 'en' */
    private $language;

    public function __construct(RokiConfig $config, $language = 'en')
    {
        $this->config   = $config;
        $this->language = ($language === 'es') ? 'es' : 'en';
    }

    /**
     * POST /payments
     *
     * @param array  $payload        Body built with roki_build_payload().
     * @param string $idempotencyKey Result of RokiClient::idempotencyKey($payload).
     * @return array The Payment object.
     * @throws RokiApiException
     */
    public function createPayment(array $payload, $idempotencyKey)
    {
        return $this->request(
            'POST',
            '/payments',
            array(201, 200), // documented 201; an idempotent replay is accepted either way
            $payload,
            array('Idempotency-Key: ' . $idempotencyKey)
        );
    }

    /**
     * GET /payments/{id} - the source of truth for whether a payment was charged.
     *
     * @param int $id
     * @return array The Payment object.
     * @throws RokiApiException
     */
    public function getPayment($id)
    {
        $id = (int) $id;
        if ($id < 1) {
            throw new InvalidArgumentException('Payment id must be a positive integer.');
        }

        return $this->request('GET', '/payments/' . $id, array(200));
    }

    /**
     * Idempotency key derived from the ORDER CONTENT.
     *
     * Verified caveat: replaying a key with a different body returns the ORIGINAL payment and no
     * error - the key wins and the body is ignored. Keying on the order id alone would therefore
     * charge a stale amount after the customer edits the cart. Hashing the canonical payload means
     * any change to amount, tax, tip or fee configuration yields a different key.
     *
     * Max length is 191; this produces at most 170 characters.
     */
    public static function idempotencyKey(array $payload)
    {
        $canonical = self::canonicalize($payload);
        $json      = json_encode($canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        $reference = isset($payload['external_reference']) ? (string) $payload['external_reference'] : 'noref';
        $reference = substr(preg_replace('/[^A-Za-z0-9_.-]/', '', $reference), 0, 100);

        return 'roki-' . $reference . '-' . hash('sha256', (string) $json);
    }

    /** Recursively sort keys so equivalent payloads always hash identically. */
    private static function canonicalize($value)
    {
        if (!is_array($value)) {
            return $value;
        }
        $out = array();
        foreach ($value as $k => $v) {
            $out[$k] = self::canonicalize($v);
        }
        if (array_keys($out) !== range(0, count($out) - 1)) {
            ksort($out);
        }

        return $out;
    }

    /**
     * @param string     $method
     * @param string     $path
     * @param int[]      $acceptStatuses
     * @param array|null $body
     * @param string[]   $extraHeaders
     * @return array Decoded JSON object.
     * @throws RokiApiException
     */
    private function request($method, $path, array $acceptStatuses, array $body = null, array $extraHeaders = array())
    {
        $headers = array_merge(
            array(
                'Authorization: Bearer ' . $this->config->secretKey(),
                'Accept: application/json',
                'Accept-Language: ' . $this->language,
                'Expect:', // stop cURL from doing a 100-continue round trip on larger bodies
            ),
            $extraHeaders
        );

        $encodedBody = null;
        if ($body !== null) {
            $encodedBody = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            if ($encodedBody === false) {
                throw new RokiApiException('Could not encode the request body as JSON.');
            }
            $headers[] = 'Content-Type: application/json';
        }

        $lastTransportError = '';

        for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
            $ch = curl_init(RokiConfig::BASE_URL . $path);
            curl_setopt_array($ch, array(
                CURLOPT_CUSTOMREQUEST  => $method,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HEADER         => false,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_TIMEOUT        => self::TIMEOUT,
                CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_SSL_VERIFYHOST => 2,
                CURLOPT_HTTPHEADER     => $headers,
                CURLOPT_USERAGENT      => 'roki-php-curl/1.0',
            ));
            if ($encodedBody !== null) {
                curl_setopt($ch, CURLOPT_POSTFIELDS, $encodedBody);
            }

            $raw    = curl_exec($ch);
            $errno  = curl_errno($ch);
            $error  = curl_error($ch);
            $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            curl_close($ch);

            // Transport failure: DNS, TLS, connect or read timeout. Retry with the SAME key.
            if ($raw === false || $errno !== 0) {
                $lastTransportError = 'cURL error ' . $errno . ': ' . $error;
                roki_log('transport failure', array('attempt' => $attempt, 'error' => $lastTransportError));
                if ($attempt < self::MAX_ATTEMPTS) {
                    usleep(250000 * $attempt); // 0.25s, 0.50s
                    continue;
                }
                throw new RokiApiException($lastTransportError, 0);
            }

            // Server-side hiccup: retry. Never retry a 4xx - the request itself is wrong.
            if ($status >= 500 || $status === 429) {
                $lastTransportError = 'HTTP ' . $status;
                if ($attempt < self::MAX_ATTEMPTS) {
                    usleep(500000 * $attempt);
                    continue;
                }
            }

            $decoded = json_decode((string) $raw, true);
            if (!is_array($decoded)) {
                throw new RokiApiException('Non-JSON response from the API.', $status, array(), (string) $raw);
            }

            if (in_array($status, $acceptStatuses, true)) {
                return $decoded;
            }

            $this->throwForStatus($status, $decoded, (string) $raw);
        }

        throw new RokiApiException('Request failed after ' . self::MAX_ATTEMPTS . ' attempts: ' . $lastTransportError, 0);
    }

    /** @throws RokiApiException always */
    private function throwForStatus($status, array $decoded, $raw)
    {
        $message = isset($decoded['message']) ? (string) $decoded['message'] : 'Unexpected HTTP ' . $status;
        // Stable across languages, unlike $message. Every error response carries it.
        $code    = isset($decoded['code']) ? (string) $decoded['code'] : '';
        $errors  = array();

        if (isset($decoded['errors']) && is_array($decoded['errors'])) {
            foreach ($decoded['errors'] as $field => $messages) {
                $errors[(string) $field] = is_array($messages) ? array_values($messages) : array((string) $messages);
            }
        }

        // 401 - header missing or key invalid. The two cases carry distinct messages, which is worth
        // logging: "header missing" points at your code, "invalid key" points at roki_settings.
        // 404 - three cases, separated by $code: not_found (the path is not there),
        //       transaction_id_expected (a transaction route got the numeric id), or
        //       payment_not_found (no such record). See RokiApiException.
        // 422 - validation or a business rule (currency not enabled on the terminal, tip_max_amount
        //       above amount, sales tax percentage over 100, expires_at in the past). Branch on the
        //       keys of $errors, never on the localized text.
        roki_log('api error', array('status' => $status, 'code' => $code, 'message' => $message, 'fields' => array_keys($errors)));

        throw new RokiApiException($message, $status, $errors, $raw, $code);
    }
}

No reintentés una creación con una llave recién generada después de un timeout. Así es como terminás con dos enlaces de pago activos para una sola orden. Reintentá con la llave que ya calculaste: para eso sirve exactamente.


3. Flujo de checkout

Creá el pago del lado del servidor, cuando la orden se confirma, persistí los identificadores antes de redirigir, verificá los campos calculados, y después mandá al cliente a checkout_url.

CREATE TABLE shop_payments (
  order_id           INT UNSIGNED    NOT NULL PRIMARY KEY,
  roki_payment_id    BIGINT UNSIGNED NULL,
  external_reference VARCHAR(191)    NOT NULL,
  idempotency_key    VARCHAR(191)    NOT NULL,
  status             VARCHAR(32)     NOT NULL DEFAULT 'pending',
  amount             DECIMAL(12,2)   NOT NULL,
  sales_tax_amount   DECIMAL(12,2)   NULL,
  service_fee_amount DECIMAL(12,2)   NULL,
  total              DECIMAL(12,2)   NULL,
  currency_iso       CHAR(3)         NULL,
  checkout_url       VARCHAR(255)    NULL,
  transaction_id     CHAR(36)        NULL,   -- UUID, never an integer
  refunded_amount    DECIMAL(12,2)   NULL,
  refund_status      VARCHAR(16)     NULL,
  paid_at            DATETIME        NULL,
  expires_at         DATETIME        NULL,
  poll_attempts      SMALLINT UNSIGNED NOT NULL DEFAULT 0,
  next_poll_at       DATETIME        NULL,
  created_at         DATETIME        NOT NULL,
  updated_at         DATETIME        NOT NULL,
  UNIQUE KEY uq_roki_payment_id (roki_payment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
<?php
// roki/payments.php - payload building, verification and the shared state machine.
declare(strict_types=1);

/** Money as a fixed 2-decimal string: the API accepts numeric strings and this avoids float drift. */
function roki_money($value)
{
    return number_format(round((float) $value, 2), 2, '.', '');
}

function roki_money_equals($a, $b)
{
    return abs((float) $a - (float) $b) < 0.005;
}

/**
 * Build the create-payment body.
 *
 * Only the field names below exist. The API IGNORES anything else and still returns 201 - naming
 * what it dropped in `warnings` - so a typo produces a misconfigured payment rather than an error.
 * Do not invent fields.
 */
function roki_build_payload(array $order)
{
    $expiresAt = new DateTime('now', new DateTimeZone('America/Tegucigalpa')); // Honduras, UTC-6
    $expiresAt->modify('+2 hours');

    $payload = array(
        'amount'             => roki_money($order['amount']),      // decimal units, NOT cents
        'currency_code'      => '340',                             // ISO 4217 numeric, HNL
        'external_reference' => 'order-' . $order['id'],           // NOT unique on the API side
        'name'               => substr('Order #' . $order['id'], 0, 191),
        'description'        => substr($order['description'], 0, 120),
        'metadata'           => array(
            // Returned untouched on retrieval and in every webhook - this is your reconciliation
            // channel. It is not shown to the customer and does not prefill the checkout form.
            'order_id'       => (string) $order['id'],
            'customer_email' => $order['customer_email'],
        ),
        'success_url'        => 'https://yoursite.com/return.php?order=' . $order['id'],
        'cancel_url'         => 'https://yoursite.com/cart.php',
        'expires_at'         => $expiresAt->format('Y-m-d H:i:s'),
        'reusable'           => false, // a one-off order link must die with the first payment
    );

    // Sales tax, computed by ROKI on the subtotal. Percentage cannot exceed 100.
    if (!empty($order['apply_sales_tax'])) {
        $payload['sales_tax_type']  = 'percentage';
        $payload['sales_tax_value'] = 15;
    }

    // Pass processing costs to the customer. The exact field name is service_fee_enabled; variants
    // such as service_fee or pass_fees_to_customer are ignored (and named in `warnings`), no fee.
    // Never replicate the fee formula - read service_fee_amount and total from the response.
    if (!empty($order['pass_fees_to_customer'])) {
        $payload['service_fee_enabled'] = true;
    }

    return $payload;
}

/**
 * Verify the response actually reflects what we asked for.
 *
 * A misspelled field returns 201 with the feature missing. The API names it in `warnings`; this
 * also compares the computed values, which no warning covers. Do both before you redirect a
 * customer to a link that charges the wrong total.
 *
 * @return string[] Problems found; empty means the payment is safe to use.
 */
function roki_verify_payment(array $payment, array $sent)
{
    $problems = array();

    foreach (array('id', 'status', 'checkout_url', 'subtotal', 'total', 'currency_iso') as $field) {
        if (!isset($payment[$field])) {
            $problems[] = 'response is missing ' . $field;
        }
    }
    if ($problems) {
        return $problems;
    }

    if (!roki_money_equals($payment['amount'], $sent['amount'])) {
        $problems[] = 'amount mismatch: sent ' . $sent['amount'] . ', got ' . $payment['amount'];
    }
    if ((string) $payment['external_reference'] !== (string) $sent['external_reference']) {
        $problems[] = 'external_reference mismatch';
    }
    if (isset($sent['currency_code']) && (string) $payment['currency'] !== (string) $sent['currency_code']) {
        $problems[] = 'currency mismatch: expected ' . $sent['currency_code'] . ', got ' . $payment['currency'];
    }
    if ($payment['status'] !== 'pending') {
        $problems[] = 'unexpected status on creation: ' . $payment['status'];
    }
    if (strpos((string) $payment['checkout_url'], 'https://') !== 0) {
        $problems[] = 'checkout_url is not https';
    }

    // Ignored-field detectors: we asked for a feature, so its computed amount must be non-zero.
    if (!empty($sent['service_fee_enabled']) && (float) (isset($payment['service_fee_amount']) ? $payment['service_fee_amount'] : 0) <= 0.0) {
        $problems[] = 'service_fee_enabled was sent but service_fee_amount is 0 - field ignored?';
    }
    if (isset($sent['sales_tax_type']) && $sent['sales_tax_type'] !== 'none'
        && (float) (isset($payment['sales_tax_amount']) ? $payment['sales_tax_amount'] : 0) <= 0.0) {
        $problems[] = 'sales_tax_type was sent but sales_tax_amount is 0 - field ignored?';
    }

    // Internal consistency of the total. Skip it when a tip is configured: a fixed tip is included in
    // total but is not one of these three components. total is always the authoritative figure.
    if (empty($sent['tip_enabled'])) {
        $expected = (float) $payment['subtotal']
            + (float) (isset($payment['sales_tax_amount']) ? $payment['sales_tax_amount'] : 0)
            + (float) (isset($payment['service_fee_amount']) ? $payment['service_fee_amount'] : 0);
        if (!roki_money_equals($expected, $payment['total'])) {
            $problems[] = 'total ' . $payment['total'] . ' does not match subtotal + tax + fee (' . roki_money($expected) . ')';
        }
    }

    return $problems;
}

/** Terminal states stop all polling and all further status changes. */
function roki_is_terminal($status)
{
    return in_array($status, array('paid', 'partially_refunded', 'refunded', 'voided', 'expired', 'disabled'), true);
}

/** Monotonic ranking so an out-of-order redelivery cannot roll a refund back to paid. */
function roki_status_rank($status)
{
    $rank = array(
        'pending'            => 0,
        'expired'            => 1,
        'disabled'           => 1,
        'paid'               => 2,
        'partially_refunded' => 3,
        'refunded'           => 4,
        'voided'             => 4,
    );

    return isset($rank[$status]) ? $rank[$status] : -1;
}

/**
 * Apply a Payment object to local state. Shared by the webhook handler and the polling fallback,
 * so both paths converge on exactly the same result.
 *
 * @return string|null The new status if it changed, null otherwise.
 */
function roki_apply_payment_state(PDO $db, array $payment)
{
    if (!isset($payment['id'], $payment['status'])) {
        return null;
    }
    $newStatus = (string) $payment['status'];
    if (roki_status_rank($newStatus) < 0) {
        roki_log('unknown status, ignored', array('status' => $newStatus));

        return null;
    }

    $stmt = $db->prepare('SELECT order_id, status FROM shop_payments WHERE roki_payment_id = ? FOR UPDATE');
    $stmt->execute(array((int) $payment['id']));
    $row = $stmt->fetch();
    if (!$row) {
        roki_log('payment not found locally', array('roki_payment_id' => $payment['id']));

        return null;
    }
    if (roki_status_rank($newStatus) < roki_status_rank($row['status'])) {
        return null; // stale event, arrived out of order
    }

    $update = $db->prepare(
        'UPDATE shop_payments SET
            status = ?, total = ?, sales_tax_amount = ?, service_fee_amount = ?,
            currency_iso = ?, transaction_id = ?, refunded_amount = ?, refund_status = ?,
            paid_at = ?, updated_at = NOW()
         WHERE roki_payment_id = ?'
    );
    $update->execute(array(
        $newStatus,
        isset($payment['total']) ? roki_money($payment['total']) : null,
        isset($payment['sales_tax_amount']) ? roki_money($payment['sales_tax_amount']) : null,
        isset($payment['service_fee_amount']) ? roki_money($payment['service_fee_amount']) : null,
        isset($payment['currency_iso']) ? $payment['currency_iso'] : null,
        isset($payment['transaction_id']) && $payment['transaction_id'] !== null ? (int) $payment['transaction_id'] : null,
        isset($payment['refunded_amount']) ? roki_money($payment['refunded_amount']) : null,
        isset($payment['refund_status']) ? $payment['refund_status'] : null,
        isset($payment['paid_at']) ? $payment['paid_at'] : null,
        (int) $payment['id'],
    ));

    if ($row['status'] === $newStatus) {
        return null;
    }

    // Queue side effects (invoice, stock, notification email) for a cron worker so the webhook
    // response stays fast. Never send mail inline from the webhook endpoint.
    $job = $db->prepare('INSERT INTO shop_jobs (kind, order_id, payload, created_at) VALUES (?, ?, ?, NOW())');
    $job->execute(array('payment_status_changed', (int) $row['order_id'], json_encode(array(
        'from' => $row['status'],
        'to'   => $newStatus,
    ))));

    return $newStatus;
}
<?php
// checkout.php - runs when the customer confirms the order.
declare(strict_types=1);
require __DIR__ . '/roki/bootstrap.php';

$order = load_confirmed_order((int) $_POST['order_id']); // your own code
$db    = roki_db();

// The API enforces no maximum amount - an 11-digit value is accepted. Enforce a sane ceiling here.
if ((float) $order['amount'] < 0.01 || (float) $order['amount'] > 500000.00) {
    render_error('The order total is outside the accepted range.');
    exit;
}

$payload        = roki_build_payload($order);
$idempotencyKey = RokiClient::idempotencyKey($payload);

// If we already created a link for this exact order content, reuse it instead of calling the API.
$stmt = $db->prepare('SELECT * FROM shop_payments WHERE order_id = ?');
$stmt->execute(array((int) $order['id']));
$existing = $stmt->fetch();

if ($existing && $existing['idempotency_key'] === $idempotencyKey
    && $existing['status'] === 'pending' && $existing['checkout_url']) {
    header('Location: ' . $existing['checkout_url'], true, 303);
    exit;
}

// Record the intent BEFORE the call, so a crash mid-request still leaves a trail to reconcile.
$db->prepare(
    'INSERT INTO shop_payments (order_id, external_reference, idempotency_key, status, amount, created_at, updated_at)
     VALUES (?, ?, ?, ?, ?, NOW(), NOW())
     ON DUPLICATE KEY UPDATE external_reference = VALUES(external_reference),
                             idempotency_key    = VALUES(idempotency_key),
                             amount             = VALUES(amount),
                             updated_at         = NOW()'
)->execute(array(
    (int) $order['id'],
    $payload['external_reference'],
    $idempotencyKey,
    'pending',
    roki_money($order['amount']),
));

try {
    $payment = roki_client()->createPayment($payload, $idempotencyKey);
} catch (RokiApiException $e) {
    if ($e->status() === 401) {
        // Missing header or invalid key: an operator problem, not a customer problem.
        roki_log('authentication failed', array('detail' => $e->getMessage()));
        render_error('Payments are temporarily unavailable. Please try again shortly.');
        exit;
    }
    if ($e->status() === 422) {
        // Branch on the stable keys of errors, never on the localized message text.
        foreach (array('amount', 'external_reference', 'name', 'currency_code', 'expires_at',
                       'sales_tax_value', 'tip_max_amount', 'metadata') as $field) {
            $detail = $e->errorFor($field);
            if ($detail !== null) {
                roki_log('validation error', array('field' => $field, 'detail' => $detail));
            }
        }
        render_error('We could not start the payment for this order.');
        exit;
    }
    if ($e->isRoutingError()) {
        roki_log('routing 404 - check the base URL, it must end in /api/connect/v1');
    }
    render_error('We could not reach the payment provider. Please try again.');
    exit;
}

// Verify BEFORE redirecting. An ignored field would otherwise charge the wrong total.
$problems = roki_verify_payment($payment, $payload);
if ($problems) {
    roki_log('payment verification failed', array('roki_payment_id' => $payment['id'], 'problems' => $problems));
    render_error('We could not start the payment for this order.');
    exit;
}

// Persist the authoritative figures. total and service_fee_amount come from the API - never recompute.
$db->prepare(
    'UPDATE shop_payments SET
        roki_payment_id = ?, status = ?, sales_tax_amount = ?, service_fee_amount = ?,
        total = ?, currency_iso = ?, checkout_url = ?, expires_at = ?,
        next_poll_at = DATE_ADD(NOW(), INTERVAL 5 MINUTE), updated_at = NOW()
     WHERE order_id = ?'
)->execute(array(
    (int) $payment['id'],
    (string) $payment['status'],
    roki_money(isset($payment['sales_tax_amount']) ? $payment['sales_tax_amount'] : 0),
    roki_money(isset($payment['service_fee_amount']) ? $payment['service_fee_amount'] : 0),
    roki_money($payment['total']),
    (string) $payment['currency_iso'],
    (string) $payment['checkout_url'],
    isset($payment['expires_at']) ? $payment['expires_at'] : null,
    (int) $order['id'],
));

header('Location: ' . $payment['checkout_url'], true, 303);
exit;

Mostrale al cliente total, no amount, cuando aplica una comisión de servicio o un impuesto: total es lo que se le cobra a la tarjeta. amount es lo que le queda neto al comercio.

Llegar a success_url no es una confirmación. Cualquiera puede escribir esa URL. La página de retorno lee el estado local, y solo llama a la API si el webhook todavía no ha llegado:

<?php
// return.php - the success_url landing page.
declare(strict_types=1);
require __DIR__ . '/roki/bootstrap.php';

$stmt = roki_db()->prepare('SELECT * FROM shop_payments WHERE order_id = ?');
$stmt->execute(array((int) $_GET['order']));
$row = $stmt->fetch();

if ($row && $row['status'] === 'pending' && $row['roki_payment_id']) {
    try {
        $payment = roki_client()->getPayment((int) $row['roki_payment_id']);
        roki_apply_payment_state(roki_db(), $payment);
        $row['status'] = $payment['status'];
    } catch (RokiApiException $e) {
        roki_log('return page lookup failed', array('status' => $e->status()));
    }
}

if ($row && $row['status'] === 'paid') {
    render_thank_you($row);
} else {
    render_pending_notice(); // "We are confirming your payment" - never claim success here
}

4. Manejador de webhooks

Reglas: verificá el HMAC sobre el cuerpo crudo antes de parsear nada, compará en tiempo constante, devolvé 400 ante una firma inválida, devolvé 200 rápido ante una válida, y hacé que el procesamiento sea idempotente sobre el id del evento, porque un evento puede reenviarse.

CREATE TABLE roki_webhook_events (
  event_id    VARCHAR(191)    NOT NULL PRIMARY KEY,
  type        VARCHAR(64)     NOT NULL,
  payment_id  BIGINT UNSIGNED NULL,
  payload     MEDIUMTEXT      NOT NULL,
  received_at DATETIME        NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
<?php
// webhook.php - the URL registered at /merchant/connect/webhooks.
declare(strict_types=1);
require __DIR__ . '/roki/bootstrap.php';

/** Read a request header without any framework. Dashes become underscores in $_SERVER. */
function roki_header($name)
{
    $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
    if (isset($_SERVER[$key])) {
        return (string) $_SERVER[$key];
    }
    if (function_exists('getallheaders')) {
        foreach (getallheaders() as $header => $value) {
            if (strcasecmp($header, $name) === 0) {
                return (string) $value;
            }
        }
    }

    return '';
}

/**
 * Verify ROKI-Signature: t={unix_timestamp},v1={hmac_sha256_hex}
 * over HMAC-SHA256(timestamp + "." + raw_body, signing_secret).
 *
 * $rawBody must be the untouched bytes. Do not trim it, do not re-encode a decoded array: any
 * change of a single byte invalidates the signature.
 */
function roki_verify_signature($rawBody, $signatureHeader, $secret, $toleranceSeconds = 300)
{
    $timestamp = null;
    $provided  = null;

    foreach (explode(',', $signatureHeader) as $part) {
        $part     = trim($part);
        $position = strpos($part, '=');
        if ($position === false) {
            continue;
        }
        $key   = substr($part, 0, $position);
        $value = substr($part, $position + 1);
        if ($key === 't') {
            $timestamp = $value;
        } elseif ($key === 'v1') {
            $provided = $value;
        }
    }

    if ($timestamp === null || $provided === null || !ctype_digit($timestamp) || !ctype_xdigit($provided)) {
        return false;
    }
    // Replay window (our own hardening, not an API requirement). Requires a correct server clock.
    if ($toleranceSeconds > 0 && abs(time() - (int) $timestamp) > $toleranceSeconds) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    return hash_equals($expected, strtolower($provided)); // constant time, length safe
}

/** Send the response and, where the SAPI allows it, release the connection before doing more work. */
function roki_respond($code, $body)
{
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    header('Content-Length: ' . strlen($body));
    echo $body;
    if (function_exists('fastcgi_finish_request')) {
        fastcgi_finish_request();
    } elseif (function_exists('litespeed_finish_request')) {
        litespeed_finish_request();
    }
}

// ---------------------------------------------------------------------------

if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
    roki_respond(405, '{"error":"method_not_allowed"}');
    exit;
}

$rawBody = file_get_contents('php://input');
if ($rawBody === false) {
    $rawBody = '';
}

$config = new RokiConfig(roki_db());
if (!roki_verify_signature($rawBody, roki_header('ROKI-Signature'), $config->webhookSecret())) {
    roki_log('webhook rejected: invalid signature');
    roki_respond(400, '{"error":"invalid_signature"}');
    exit;
}

// Only now is it safe to parse.
$event = json_decode($rawBody, true);
if (!is_array($event) || !isset($event['id'], $event['type'], $event['data'])
    || !is_array($event['data']) || !isset($event['data']['id'])) {
    roki_respond(400, '{"error":"malformed_event"}');
    exit;
}

$eventId   = (string) $event['id'];
$eventType = (string) $event['type'];
$payment   = $event['data']; // a full Payment object

$knownTypes = array(
    'payment.approved',
    'payment.failed',
    'payment.expired',
    'payment.voided',
    'payment.refunded',
    'payment.partially_refunded',
);
if (!in_array($eventType, $knownTypes, true)) {
    // Accept and ignore, so an unrecognised future type is not retried forever.
    roki_log('unknown event type, acknowledged', array('type' => $eventType));
    roki_respond(200, '{"received":true}');
    exit;
}

$db = roki_db();

// Idempotency keyed on the event id: the unique primary key is the lock. A redelivery of an event
// we already stored short-circuits here, so processing runs exactly once.
try {
    $insert = $db->prepare(
        'INSERT INTO roki_webhook_events (event_id, type, payment_id, payload, received_at)
         VALUES (?, ?, ?, ?, NOW())'
    );
    $insert->execute(array($eventId, $eventType, (int) $payment['id'], $rawBody));
} catch (PDOException $e) {
    if ($e->getCode() === '23000') { // duplicate primary key
        roki_respond(200, '{"received":true,"duplicate":true}');
        exit;
    }
    roki_log('could not record event', array('event_id' => $eventId));
    roki_respond(500, '{"error":"storage"}'); // 5xx so ROKI redelivers
    exit;
}

try {
    $db->beginTransaction();

    // payment.failed reports a declined attempt; 'failed' is NOT a payment status, so the link stays
    // pending and the customer can retry. Every other type carries the new state in data.status.
    if ($eventType !== 'payment.failed') {
        roki_apply_payment_state($db, $payment);
    } else {
        roki_log('payment attempt failed', array('roki_payment_id' => $payment['id']));
    }

    $db->commit();
} catch (Exception $e) {
    if ($db->inTransaction()) {
        $db->rollBack();
    }
    // Remove the dedupe row so the redelivery is actually reprocessed, then ask for a retry.
    $db->prepare('DELETE FROM roki_webhook_events WHERE event_id = ?')->execute(array($eventId));
    roki_log('event processing failed', array('event_id' => $eventId, 'error' => $e->getMessage()));
    roki_respond(500, '{"error":"processing"}');
    exit;
}

roki_respond(200, '{"received":true}');

Notas que deciden si esto funciona en producción:


5. Respaldo por consulta periódica

Los webhooks se pierden: un parpadeo de DNS, un certificado vencido, una regla de WAF, una ventana de mantenimiento. Consultá los pagos que todavía estás esperando. GET /payments puede listar un rango de fechas, pero consultar los ids que guardaste localmente al momento de crearlos.

<?php
// cron/poll_pending.php - run every 5 minutes:
//   */5 * * * * /usr/bin/php /var/www/cron/poll_pending.php >> /var/log/roki-poll.log 2>&1
declare(strict_types=1);
require __DIR__ . '/../roki/bootstrap.php';

const POLL_MAX_ATTEMPTS = 14;
/** Minutes to wait before attempt N. The tail keeps a stale link cheap to watch. */
const POLL_BACKOFF = array(1, 2, 5, 10, 15, 30, 60, 120, 240, 360, 720, 720, 1440, 1440);

$db = roki_db();

$stmt = $db->prepare(
    "SELECT order_id, roki_payment_id, poll_attempts
       FROM shop_payments
      WHERE status = 'pending'
        AND roki_payment_id IS NOT NULL
        AND poll_attempts < ?
        AND (next_poll_at IS NULL OR next_poll_at <= NOW())
      ORDER BY next_poll_at ASC
      LIMIT 50"
);
$stmt->execute(array(POLL_MAX_ATTEMPTS));
$rows = $stmt->fetchAll();

$client = roki_client();

foreach ($rows as $row) {
    $attempts = (int) $row['poll_attempts'] + 1;
    $delay    = POLL_BACKOFF[min($attempts, count(POLL_BACKOFF)) - 1];

    try {
        $payment = $client->getPayment((int) $row['roki_payment_id']);
    } catch (RokiApiException $e) {
        if ($e->status() === 401) {
            // The key is wrong or was rotated badly. Stop: every remaining call would fail too.
            roki_log('polling aborted, authentication failed');
            break;
        }
        if ($e->isPaymentNotFound()) {
            // The id does not exist for this key - typically a payment created with the other
            // environment's key. It will never resolve; flag it for a human.
            $db->prepare("UPDATE shop_payments SET poll_attempts = ?, next_poll_at = NULL, updated_at = NOW() WHERE order_id = ?")
               ->execute(array(POLL_MAX_ATTEMPTS, (int) $row['order_id']));
            roki_log('payment id unknown to this key', array('order_id' => $row['order_id']));
            continue;
        }
        if ($e->isRoutingError()) {
            roki_log('routing 404 while polling - base URL misconfigured');
            break;
        }
        // Transport error or 5xx: back off and try again next run.
        $db->prepare('UPDATE shop_payments SET poll_attempts = ?, next_poll_at = DATE_ADD(NOW(), INTERVAL ? MINUTE), updated_at = NOW() WHERE order_id = ?')
           ->execute(array($attempts, $delay, (int) $row['order_id']));
        continue;
    }

    $db->beginTransaction();
    // Exactly the same state machine the webhook uses, so both paths agree and neither can
    // double-apply: the rank check makes a late webhook after a poll a no-op.
    $changed = roki_apply_payment_state($db, $payment);
    $db->commit();

    $status = (string) $payment['status'];
    if (roki_is_terminal($status)) {
        $db->prepare('UPDATE shop_payments SET next_poll_at = NULL, updated_at = NOW() WHERE order_id = ?')
           ->execute(array((int) $row['order_id']));
        if ($changed !== null) {
            roki_log('resolved by polling', array('order_id' => $row['order_id'], 'status' => $status));
        }
        continue;
    }

    $db->prepare('UPDATE shop_payments SET poll_attempts = ?, next_poll_at = DATE_ADD(NOW(), INTERVAL ? MINUTE), updated_at = NOW() WHERE order_id = ?')
       ->execute(array($attempts, $delay, (int) $row['order_id']));
}

// Anything still pending after the whole backoff schedule needs a human. An unpaid link eventually
// reports status 'expired' on its own, so a row stuck at 'pending' past expires_at means the polling
// itself is broken, not the payment.
$stuck = $db->prepare(
    "SELECT order_id, roki_payment_id, expires_at
       FROM shop_payments
      WHERE status = 'pending' AND poll_attempts >= ? AND created_at < DATE_SUB(NOW(), INTERVAL 1 DAY)"
);
$stuck->execute(array(POLL_MAX_ATTEMPTS));
foreach ($stuck->fetchAll() as $row) {
    roki_log('needs manual review in the merchant portal', array(
        'order_id'        => $row['order_id'],
        'roki_payment_id' => $row['roki_payment_id'],
        'expires_at'      => $row['expires_at'],
    ));
}

La consulta periódica y los webhooks no son alternativas: corré ambos. El webhook te da segundos de latencia; el proceso de consulta periódica garantiza la consistencia eventual cuando el webhook nunca llega. Como ambos pasan por roki_apply_payment_state() con la guarda de rango, gane quien gane la carrera, el resultado es el mismo y el job de efecto secundario se encola una sola vez.


Checklist previo al lanzamiento

Reversos, recibos y los modos más nuevos

Los ejemplos por stack de arriba cubren el modo 1 de punta a punta. Estos agregan las operaciones que se introdujeron después. La forma se traslada a cualquier lenguaje: lo que importa es qué identificador pasás y qué credencial firma la llamada.

Revertir un cobro: primero la anulación, el reembolso como respaldo

El error más común de todos es pasar el id del pago. Cada llamada acá recibe el UUID de la transacción, que aparece en transaction_id una vez que el pago está pagado.

/**
 * Reverse a charge in full. Void is immediate and pre-settlement; refund applies after.
 * Trying void first is deliberate: it is cheaper, instant for the customer, and avoids
 * a settled-then-refunded round trip.
 *
 * @param string $transactionId  UUID from the payment, NOT the numeric payment id.
 */
public function reverse(string $transactionId, float $amount, string $reason): array
{
    $void = $this->request('POST', "/payments/{$transactionId}/void", ['reason' => $reason]);
    if ($void['status'] === 200) {
        return ['method' => 'void', 'payment' => $void['body']];
    }

    // 422 with errors.void = not voidable (already settled, already voided, wrong state).
    // Anything else is a real failure worth surfacing.
    if ($void['status'] !== 422) {
        throw new RokiException("Void failed: HTTP {$void['status']}");
    }

    $refund = $this->request('POST', "/payments/{$transactionId}/refund", [
        'amount' => $amount,
        'reason' => $reason,
    ]);
    if ($refund['status'] === 200) {
        return ['method' => 'refund', 'payment' => $refund['body']];
    }

    // A processor rejection such as "Invalid transaction" means the transaction is not
    // refundable yet. It is not a bug in your payload - retry later.
    $why = $refund['body']['errors']['refund'][0] ?? 'unknown';
    throw new RokiException("Neither void nor refund succeeded: {$why}");
}
// Node equivalent. Same rule: transactionId is a UUID.
async function reverse(transactionId, amount, reason) {
  const voided = await roki('POST', `/payments/${transactionId}/void`, { reason });
  if (voided.status === 200) return { method: 'void', payment: voided.body };
  if (voided.status !== 422) throw new Error(`Void failed: HTTP ${voided.status}`);

  const refunded = await roki('POST', `/payments/${transactionId}/refund`, { amount, reason });
  if (refunded.status === 200) return { method: 'refund', payment: refunded.body };

  throw new Error(`Neither void nor refund succeeded: ${refunded.body?.errors?.refund?.[0] ?? 'unknown'}`);
}

Si te sale un 404 con code: "transaction_id_expected", pasaste el id numérico del pago. El endpoint está ahí; el identificador es el equivocado - mandá $payment['transaction_id'], no $payment['id']. Un code de not_found sería el otro problema, distinto: la ruta misma no existe, así que revisá la URL base.

Recibo

// Metadata: payment_id, transaction_id and a public receipt_url you can link to.
$receipt = $this->request('GET', "/payments/{$transactionId}/receipt");

// The PDF is a binary body - stream it, do not json_decode it.
$pdf = $this->requestRaw('GET', "/payments/{$transactionId}/receipt/download");
Storage::put("receipts/{$transactionId}.pdf", $pdf);

Modo 2: la mitad del backend de los componentes embebidos

El navegador produce un tok_* y lo manda por POST a tu servidor. Tu servidor agrega el monto y la llave secreta. El monto nunca viene del navegador.

// POST /your-backend/confirm-payment   (called by your own frontend)
public function confirmEmbedded(Request $request)
{
    $order = Order::findOrFail($request->input('order_id'));

    // Authoritative amount: from YOUR order, never from the client payload.
    $res = Http::withToken($this->secretKey())
        ->acceptJson()
        ->post('https://aura.roki.systems/api/connect/embed/confirm', [
            'amount'               => $order->total,
            'currency_code'        => 'HNL',
            'external_reference'   => (string) $order->id,
            'payment_token'        => $request->input('payment_token'),   // tok_*
            'publishable_key'      => $this->publishableKey(),
            'success_redirect_url' => route('orders.success', $order),
            'failed_redirect_url'  => route('orders.failed', $order),
        ]);

    $body = $res->json();

    switch ($body['status'] ?? null) {
        case 'approved':
            // transaction_details carries roki_commission / isv / expected_settlement.
            // Commercially sensitive: store if you need it, never show it to the cardholder.
            $order->markPaid($body['transaction_id']);
            return response()->json(['status' => 'approved', 'transaction_id' => $body['transaction_id']]);

        case 'pending':
            // 3-D Secure. Send the customer to authentication_url - redirect, or a VISIBLE
            // modal iframe. A hidden 1x1 iframe silently breaks every challenge flow.
            // The outcome arrives by webhook. Do NOT treat this as a failure.
            $order->markAwaitingAuthentication($body['transaction_id'] ?? null);
            return response()->json([
                'status'             => 'pending',
                'authentication_url' => $body['authentication_url'],
            ]);

        default:
            // Processor decline: branch on Errors[0].Code, never on the message text.
            $code = $body['Errors'][0]['Code'] ?? $body['IsoResponseCode'] ?? 'declined';
            Log::warning('ROKI embedded decline', ['order' => $order->id, 'code' => $code]);
            return response()->json(['status' => 'declined', 'code' => $code], 402);
    }
}

Modo 3A: guardar una tarjeta y cobrarla después

El primer paso es un webhook. La tarjeta se guarda solo cuando el cliente marca la casilla, así que tratá el evento como el disparador, no como algo que vos pedís.

// Inside your webhook handler, alongside payment.approved etc.
case 'payment_method.saved':
    $pm = $event['data'];
    SavedCard::updateOrCreate(
        ['roki_payment_method_id' => $pm['id']],          // pm_*  - opaque, never a PAN
        [
            'customer_id' => $this->resolveCustomer($event),
            'brand'       => $pm['card_brand'],
            'last_four'   => $pm['last_four'],
            'exp_month'   => $pm['exp_month'],
            'exp_year'    => $pm['exp_year'],
            'is_default'  => $pm['is_default'] ?? false,
        ],
    );
    break;

Cobrarla después:

/**
 * Charge a saved card. Idempotency-Key is MANDATORY here - unlike payment creation,
 * where it is only recommended. Omitting it returns 422.
 *
 * Derive the key from what makes this charge unique so a retry is safe but a genuinely
 * new charge is not swallowed.
 */
public function chargeSavedCard(SavedCard $card, Order $order): array
{
    $idempotencyKey = 'charge-' . $order->id . '-' . substr(
        hash('sha256', $order->id . '|' . $order->total . '|' . $card->roki_payment_method_id), 0, 32
    );

    $res = Http::withToken($this->secretKey())
        ->withHeaders(['Idempotency-Key' => $idempotencyKey])
        ->acceptJson()
        ->post("https://aura.roki.systems/api/connect/v1/payment-methods/{$card->roki_payment_method_id}/charge", [
            'amount'             => $order->total,
            'currency_code'      => 'HNL',        // alphabetic here; payment creation uses "340"
            'external_reference' => (string) $order->id,
        ]);

    $body = $res->json();

    if (($body['status'] ?? null) === 'approved') {
        // The returned id IS the transaction UUID: use it for void/refund/receipt.
        $order->markPaid($body['id']);
        return $body;
    }

    // 422 tokenized_charge_disabled = saved cards are not enabled on this ROKI account.
    // Not a card problem: do not mark the card unusable, and do not retry.
    if (($body['code'] ?? null) === 'tokenized_charge_disabled') {
        throw new RokiException('Saved cards are not enabled for this ROKI account');
    }

    // 404 = the card was revoked or never belonged to you. Stop retrying and ask for a new card.
    if ($res->status() === 404) {
        $card->markUnusable();
        throw new RokiException('Saved card is no longer usable');
    }

    $code = $body['Errors'][0]['Code'] ?? $body['IsoResponseCode'] ?? 'declined';
    throw new RokiException("Charge declined: {$code}");
}

Listar las tarjetas de un cliente, y revocar una:

$cards = $this->request('GET', '/payment-methods', query: [
    'customer' => ['identity_number' => $customer->identity_number],   // preferred identifier
]);
// With mode 3A enabled, a customer with no saved cards is 200 with {"data": []}, not a 404.
// Without it, this answers 422 tokenized_charge_disabled (23.1.0).

$this->request('DELETE', "/payment-methods/{$card->roki_payment_method_id}");

Nunca llamés a ninguna de estas desde un navegador. Todas requieren sk_*. Un botón de "pagar con tarjeta guardada" llama a tu propio servidor, que luego llama a ROKI.