ROKIConnect

Integration examples by stack

19.0 Single-file clients, if you would rather not write one

Before writing a client by hand, know that one already exists for PHP, TypeScript and Python. Each is a single file with no dependencies, generated from openapi.yaml and regenerated on every deployment, so it cannot describe a contract the API no longer has.

Language Download Requires
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+, or any runtime with fetch
Python https://mcp.roki.la/sdk/roki_connect.py Python 3.10+, standard library only

They carry the three defences this API needs and a hand-written client usually forgets:

Each also ships verifyWebhook / verify_webhook, which computes the HMAC over timestamp + "." + raw_body and compares in constant time (14.4).

19.1 Coming from another gateway

If the developer knows Stripe or Mercado Pago, the mistakes are predictable: they write amount_cents, payment_method_types, notification_url, line_items. Forty-five such names are mapped to their ROKI equivalent - or to an explicit "no equivalent, do this instead" - and the mapping is applied by both roki_validate_request and the clients above.

The four that matter most:

Elsewhere In ROKI
amount_cents: 150000 amount: 1500.00 - decimal units, never minor units
payment_method_types Nothing. The mode is the endpoint you call (see 1).
notification_url per payment Nothing. Webhooks are registered once per environment (14).
line_items / items Nothing. Sum the cart yourself and send one amount.

Ask roki_validate_request with the payload you were about to send; it names the gateway your habit comes from and what ROKI calls the same thing.

19.2 Full examples

Four complete, runnable integrations. All four follow the same shape: credentials from a runtime configuration store, an API client with content-derived idempotency, the checkout flow, a webhook handler that verifies the signature over the raw body, and a polling fallback.

Pick the one closest to your stack; the structure transfers directly to any other.

PHP / Laravel

Tested against PHP 8.2+ and Laravel 11/12, using the Http facade. Everything the customer's money depends on happens server-side; the secret key never reaches a browser.

Two rules drive the whole design and are worth stating up front:

Void, refund and receipts exist and are keyed by the transaction UUID (transaction_id), never by the numeric payment id. There is still no list endpoint. Void and refund also fire webhooks when a human performs them in the portal, so a handler must not assume only its own calls change state.

1. Configuration: keys from a settings table, not from code

Both secrets live in a settings row, encrypted at rest. Rotating a key is an UPDATE, not a deploy.

// 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}");
    }
}

Rotation, with no redeploy and no restart:

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

The cache TTL bounds how long old workers keep the previous key: 60 seconds. If you run more than one node, keep the TTL short rather than sharing a cache store.

2. The API client

Exceptions first, so the calling code can branch on the real failure modes.

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

The idempotency key is a fingerprint of the order content. This is not decoration: replaying a key with a different body returns the original payment with no error at all - the key wins and the new body is discarded. A key derived only from the order id would silently charge the old amount after the customer edits the cart.

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

Canonicalise amounts before fingerprinting (150.5 and 150.50 must not produce two keys); the payload builder in step 3 rounds to two decimals for exactly that reason.

// 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}"),
        };
    }
}

On a 422, message holds only the first error (suffixed with (and N more errors)); errors holds all of them keyed by field. Branch on the keys, never on the message text - the text is localized by Accept-Language, the keys are stable.

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

Bind it once:

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

3. Checkout: create on order confirmation, verify, then redirect

Local mirror of the payment. The API has no list endpoint, so this table is the only handle you have on in-flight payments.

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

The payload builder. Every key here is a real field name - a typo would not fail, it would ship a misconfigured payment.

// 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 !== '');
    }
}

The guard that catches a silently ignored field. Run it on every create, before anything is persisted or any customer is redirected.

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

Register the endpoint in the portal at /merchant/connect/webhooks. URLs and signing secrets are separate per environment; a sandbox secret will never validate a production event.

The route must be exempt from CSRF - ROKI has no session token. In Laravel 11/12:

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

On Laravel 10 and earlier, add 'webhooks/roki' to $except in app/Http/Middleware/VerifyCsrfToken. (Registering it in routes/api.php instead also avoids CSRF, but then the portal URL must include the /api prefix.)

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

One writer serves both the webhook and the poller, so the two paths can never disagree.

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

Webhooks fail: a deploy, a DNS blip, a rotated secret. There is no endpoint that lists payments, so the sweep is driven from your own roki_payments rows.

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

After the cutoff, stop polling and alert a human - never guess a terminal status locally. A link past its expires_at will be reported as expired by the API itself; if it still reads pending days later, that is a question for support, not a value to invent.

An end-to-end smoke test worth keeping in CI, using 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);

That test is the whole point of ResponseGuard: it is the only signal you will ever get that a field was ignored.


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

Runnable ESM for Node 20+ (native fetch, AbortController, node:crypto). Nothing is hardcoded: the secret key and the webhook signing secret are read from a settings table at runtime, so rotating either one is a database update, not a redeploy.

Two rules drive most of the defensive code below:

Void, refund and receipts exist and are keyed by the transaction UUID (transaction_id), never by the numeric payment id. There is no list endpoint.

Storage. Postgres via pg; adapt the SQL to your 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. Configuration from a settings store

The key prefix alone selects the environment (sk_test_ sandbox, sk_live_ production) over identical routes, so the environment is derived, never configured separately. A short TTL cache keeps the hot path off the database while still picking up a rotated key within a minute.

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

The secret key is server-side only. It must never reach a browser bundle, a mobile app, or a client-side environment variable.

2. API client

createPayment() and getPayment(), with a per-attempt AbortController timeout, a content-derived Idempotency-Key, retries that reuse that key, and typed errors that carry the per-field errors object from a 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 };

Handling a 422 at the call site branches on the stable errors keys, not on the localized text:

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. Checkout flow

Create the payment when the order is confirmed, claim the idempotency key in the database before the network call so a double-submitted form cannot produce two payments, persist the payment, verify the computed fields, then redirect.

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

The customer landing on success_url is not confirmation: anyone can navigate to that URL. The thank-you page must read the locally stored status, which only the webhook or the poller sets.

4. Webhook handler

The signature is computed over the raw request bytes. express.json() consumes the stream and leaves you only a parsed object; re-serializing it with JSON.stringify is not byte-identical to what was signed. Key order can change, insignificant whitespace is gone, 105.50 becomes 105.5, and non-ASCII escaping differs. A single differing byte changes the HMAC, so every webhook would fail verification. Mount express.raw() on the webhook path, before any global JSON parser.

// 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);

If a global parser is unavoidable (mounted by a framework you do not control), capture the buffer instead and verify against 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');
  }
}

The state machine is shared with the poller so both paths converge, and out-of-order or replayed deliveries cannot walk a payment backwards:

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

Webhooks fail: your endpoint is down during a deploy, the delivery is dropped, DNS blips. GET /payments/{id} is the other source of truth. There is no list endpoint (GET /payments answers 405), so poll the ids from your own table with a backoff, and funnel results through the same 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)],
  );
}

Drive it from a scheduler that guarantees a single runner (cron plus an advisory lock, or your job queue). An in-process interval is fine for one instance:

// 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, async, fully type-hinted; pydantic v2 + SQLAlchemy 2.0 async)

Async integration with httpx, fully type-hinted. Requires Python 3.11+, fastapi, httpx, pydantic v2, sqlalchemy 2.0 (async), and tzdata on Windows.

Three properties of this API drive every decision below:

Functions like load_confirmed_order, fulfil_order and reverse_fulfilment are your own domain code; only the ROKI-facing parts are spelled out here.

1. Configuration: credentials from a settings table

The secret key and the webhook signing secret live in a settings table, not in the image or the environment, so rotating a key is an UPDATE plus a cache invalidation. The environment comes from the key prefix (sk_test_ sandbox, sk_live_ production): the base URL is identical for both, so the prefix is the only thing deciding where a charge lands.

# 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)

The webhook URL and its signing secret are registered per environment in the merchant portal. Update both rows in one transaction when you move a merchant between sandbox and live, or verified webhooks start bouncing against a stale secret.

2. The API client

Two operations exist: createPayment (POST /payments) and getPayment (GET /payments/{id}).

Request and response models

extra="forbid" on the request is the only place a typo can be caught - the API will not do it for you. extra="allow" on the response keeps the client working when ROKI adds a field.

# 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

Errors

# 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)."""

The client

Timeouts are split so a slow read cannot consume the connect budget. Retries cover transport failures and 5xx only, and they are safe precisely because of the Idempotency-Key: a replay returns the original payment instead of creating a second one.

# 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")

Build it once per process so the connection pool is shared:

# 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

Handling 401 / 404 / 422 at the call site

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. Checkout flow

Create the payment when the order is confirmed, persist before the network call and again after it, verify the computed fields, then redirect.

One row per attempt, not per order. external_reference is not unique, so nothing on ROKI's side stops an order from acquiring two live links; the attempt row plus the content-derived Idempotency-Key is what prevents it. The row also stores attempt_started_at, which seeds expires_at and therefore makes the request body byte-identical across retries of the same attempt.

# 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)

Building a deterministic request

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

Because attempt and expires_at both change on a new attempt, a fresh attempt after an expired link produces a different key and therefore a genuinely new payment - while a retry inside the same attempt replays the old key and gets the same payment back.

Verifying the response

The only defence against a silently ignored field is checking the response. Note what is absent here: any recomputation of service_fee_amount. The rates behind it are per-merchant configuration that can change; service_fee_amount and total from the response are the only correct source.

# 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)

The 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)

The return page is not a confirmation

@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. Webhook handler

The HMAC covers the exact bytes ROKI sent. Read the raw body with await request.body() before any parsing, and never re-serialize: a re-encoded body differs in key order, whitespace or unicode escaping, and the signature will not match. For the same reason this route takes a bare Request and declares no Pydantic body parameter - the moment FastAPI parses and re-validates a body model, the exact bytes stop being the thing you are verifying.

# 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 is the PostgreSQL dialect. On MySQL use sqlalchemy.dialects.mysql.insert(...).prefix_with("IGNORE"); on SQLite, the sqlite dialect has the same on_conflict_do_nothing. What matters is that the insert commits before the 200 and that rowcount tells you whether this delivery is the first.

# 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()

One place applies a ROKI payment state locally, shared by the webhook and the poller:

# 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

For a high-value order, re-reading the payment before shipping costs one call and removes any doubt about what the customer was actually charged:

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. Polling fallback

Webhooks get lost: a deploy restarts the process mid-request, a proxy times out, DNS blips, or a signature is rejected because a secret was rotated in the wrong order. Every payment left pending therefore carries a next_poll_at, and a sweeper walks the due ones with a widening backoff. It calls getPayment and nothing else - there is nothing else to call.

# 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

Start it from the lifespan alongside the shared HTTP client. Every worker process runs its own copy: with_for_update(skip_locked=True) keeps that correct, but a dedicated worker or a job queue (arq, Celery beat) is cleaner than N web workers all sweeping.

# 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)

Plain PHP 7.4+ with cURL (no framework, no Composer)

Target: PHP 7.4 or newer with the curl, json and pdo_mysql extensions. No Composer, no external libraries - cURL and native PHP functions only. Every file below is standalone and can be dropped into a hand-built site or an older CMS.

Amounts are decimal units (150.50 = L 150.50), never cents. Currency is HNL, ISO numeric "340".

Endpoints used, and the only ones that exist:

Call Route
Create a payment POST https://aura.roki.systems/api/connect/v1/payments
Retrieve a payment GET https://aura.roki.systems/api/connect/v1/payments/{id}

Void, refund and receipts are keyed by the transaction UUID. There is no list endpoint. Reversals are also performed by a human in the merchant portal, and your integration learns about them through webhooks.


1. Configuration: keys in a settings table, never in code

Hardcoding sk_live_... in a PHP file means a key rotation needs a redeploy, and the key ends up in backups, in the CMS file manager and, sooner or later, in a public repository. Store both secrets in a settings table so rotating them is a single 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));
}

Rotation procedure: UPDATE roki_settings SET value = ?, updated_at = NOW() WHERE name = ?. The next request picks it up. If you switch between sk_test_ and sk_live_, you must update roki_webhook_secret in the same transaction - the signing secret and the webhook URL are per environment, and a live key with a sandbox signing secret rejects every event with a 400.


2. The API client

Three things this client gets right and most hand-written ones do not:

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

Do not retry a create with a freshly generated key after a timeout. That is how you end up with two live payment links for one order. Retry with the key you already computed - that is exactly what it is for.


3. Checkout flow

Create the payment server-side, when the order is confirmed, persist the identifiers before you redirect, verify the computed fields, then send the customer to 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;

Show the customer total, not amount, when a service fee or tax applies - total is what the card is charged. amount is what the merchant nets.

Landing on success_url is not confirmation. Anyone can type that URL. The return page reads local state, and only calls the API if the webhook has not landed yet:

<?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. Webhook handler

Rules: verify the HMAC over the raw body before parsing anything, compare in constant time, return 400 on an invalid signature, return 200 fast on a valid one, and make processing idempotent on the event id because an event can be redelivered.

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

Notes that decide whether this works in production:


5. Polling fallback

Webhooks get lost: a DNS blip, an expired certificate, a WAF rule, a maintenance window. Poll the payments you are still waiting on. There is no list endpoint, so you can only poll ids you stored locally at creation.

<?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'],
    ));
}

Polling and webhooks are not alternatives - run both. The webhook gives you seconds of latency; the poller guarantees eventual consistency when the webhook never arrives. Because both funnel through roki_apply_payment_state() with the rank guard, whichever wins the race, the outcome is the same and the side-effect job is enqueued once.


Pre-launch checklist

Reversals, receipts and the newer modes

The per-stack examples above cover mode 1 end to end. These add the operations introduced later. The shape transfers to any language: what matters is which identifier you pass and which credential signs the call.

Reversing a charge - void first, refund as fallback

The single most common mistake is passing the payment id. Every call here takes the transaction UUID, which appears in transaction_id once the payment is paid.

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

If you get "The route ... could not be found.", you passed the numeric payment id. The endpoint is there; the identifier is wrong.

Receipt

// 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);

Mode 2 - the backend half of embedded components

The browser produces a tok_* and posts it to your server. Your server adds the amount and the secret key. The amount never comes from the browser.

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

Mode 3A - saving a card, then charging it later

Step one is a webhook. The card is saved only when the customer ticks the box, so treat the event as the trigger, not something you request.

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

Charging it later:

/**
 * 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}");
}

Listing a customer's cards, and revoking one:

$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}");

Never call any of these from a browser. They all require sk_*. A "pay with saved card" button calls your own server, which then calls ROKI.