ROKIConnect

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. Sigue sin existir un endpoint de listado. 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 with {"message":"Pago no encontrado."} - the route exists, the payment does not. */
final class RokiPaymentNotFoundException extends RokiException {}

/** 404 with "The route ... could not be found." - the path itself does not exist in the API. */
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}"));
    }

    /** '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();

        throw match ($response->status()) {
            401 => new RokiAuthException($message),
            // Two different 404s. The routing one always arrives in English and means
            // the path does not exist - typically a call to a removed endpoint.
            404 => str_contains($message, 'could not be found')
                ? new RokiRouteNotFoundException($message)
                : 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. La API no tiene endpoint de listado, así que esta tabla es el único asidero que tenés sobre los pagos en curso.

// 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 silenciosamente. Ejecutala en cada creación, antes de persistir nada y antes de redirigir a ningún cliente.

// 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. No existe ningún endpoint que liste pagos, así que el barrido se maneja desde tus propias filas de roki_payments.

// 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.

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. No hay endpoint de listado.

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 with {"message":"Pago no encontrado."} - the route exists, the payment does not
 *  (wrong id, or a key from the other environment). */
export class RokiPaymentNotFoundError extends RokiError {}

/** 404 with "The route ... could not be found." - the path itself does not exist.
 *  Void, refund and receipts are keyed by the transaction UUID, not the payment id.
 *  A numeric id here produces a routing 404. There is no list endpoint. */
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,
  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:
      // Routing 404 always arrives in English and ignores Accept-Language.
      if (/could not be found/i.test(message)) {
        throw new RokiRouteNotFoundError(`ROKI route does not exist: ${method} ${path}`, { 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 are silently ignored
    reusable: false,
  };
}

/**
 * Verifies that the payment ROKI created is the payment we asked for.
 * This is the only defense against the silent-ignore behaviour: a dropped field
 * produces a 201 and a payment missing tax, tip or fee.
 */
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 also arrive when a human voids or refunds from the merchant portal:
      // the portal is currently the only way to run them. 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. No hay endpoint de listado (GET /payments responde 405), así que 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

Existen dos operaciones: createPayment (POST /payments) y getPayment (GET /payments/{id}).

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
    silently drops any field it does not recognise, so a typo such as `service_fee` instead of
    `service_fee_enabled` yields a payment with no fee pass-through and no error anywhere.
    """

    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 RokiRoutingError(RokiError):
    """404 'The route ... could not be found.': the PATH does not exist.

    This is what void, refund, receipt and list-payments return. Seeing it in production means code
    was written against an endpoint this API does not have.
    """


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, including the 405 from calling GET /payments (there is no listing)."""

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,
    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

_ROUTING_404 = re.compile(r"could not be found", re.IGNORECASE)
_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 ""
    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:
        # Two different 404s. The routing one is always English and means the path is not there.
        if _ROUTING_404.search(message):
            return RokiRoutingError(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 two operations this API actually has.

    Do not add void(), refund(), receipt() or list_payments(): those paths return a routing 404.
    Voids and refunds can be called from your backend with the transaction UUID, and 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,
    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 code, not the data.
    logger.critical("roki route missing: %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. There is no list endpoint: a payment id you did not store
    is a payment you cannot reach again."""

    __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. With no list endpoint it
        # is the reconciliation channel. 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 única defensa contra un campo ignorado en silencio es revisar la respuesta. 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 a silently 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. There is no cancel or
        # void endpoint, so flag the row and let the link expire on its own; a human closes the loop
        # from the merchant portal.
        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 even when a human performs the action in the merchant portal, which today is the only way
#: to void or refund. This is the reconciliation channel for those operations.
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:
            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 - no hay nada más que llamar.

# 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, y los únicos que existen:

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. No hay endpoint de listado. 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 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 = '')
    {
        parent::__construct($message, $status);
        $this->status = (int) $status;
        $this->errors = $errors;
        $this->body   = (string) $body;
    }

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

    /** @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" rather than
     * "no payment with that id". Routing errors always arrive in English and ignore Accept-Language.
     * A routing 404 on /payments/{id} means your base URL is wrong.
     */
    public function isRoutingError()
    {
        return $this->status === 404 && stripos($this->getMessage(), 'could not be found') !== false;
    }

    /** True when the payment id does not exist for the key in use (wrong id, or wrong environment). */
    public function isPaymentNotFound()
    {
        return $this->status === 404 && !$this->isRoutingError();
    }
}
<?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;
        $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 - either the payment does not exist for this key, or the path does not exist (see
        //       RokiApiException::isRoutingError). Both are surfaced through the exception.
        // 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, 'message' => $message, 'fields' => array_keys($errors)));

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

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 SILENTLY IGNORES anything else and still returns 201,
 * 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 in silence and produce 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.
 *
 * This is the only defence against the silent-ignore behaviour: a misspelled field returns 201 with
 * the feature missing. Compare 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';
    }

    // Silent-ignore 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. A silently 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. No hay endpoint de listado, así que solo podés 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 "The route ... could not be found.", pasaste el id numérico del pago. El endpoint está ahí; el identificador es el equivocado.

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;
    }

    // 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
]);
// A customer with no saved cards is 200 with {"data": []}, not a 404.

$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.