# ROKI Connect - Canonical Integration Guide --- ## 1. What ROKI Connect is It lets a merchant's server accept card payments **three ways**. In every one, the merchant never receives, transmits or stores the card number: card entry always happens on a ROKI-controlled surface. | Mode | What it is | Card entry | Frontend needs | Backend needs | |---|---|---|---|---| | **1 - Hosted checkout** | Redirect the customer to a ROKI page | ROKI page | nothing | `sk_*` creates the payment | | **2 - Embedded components** | ROKI card fields in an iframe on your own site | ROKI iframe | `pk_*` | `sk_*` confirms with the amount | | **3A - Tokenized payments** | Charge a card the customer already saved | none | nothing | `sk_*` charges an opaque `pm_*` | Mode 3B is not available yet. **Start with mode 1.** It is the simplest, needs no frontend work, and every other mode reuses its payment object, its webhooks and its void/refund/receipt endpoints. Reach for mode 2 when the merchant wants the payment step to look like their own site, and mode 3A when you need to charge a returning customer without asking for the card again. In modes 2 and 3A the customer's browser talks **only to your own backend**. Your backend is the only place that holds `sk_*` and calls ROKI directly. A "pay with saved card" button must call your server, never a ROKI secret-key endpoint, and `sk_*` must never appear in browser code. The practical consequence is that the merchant stays outside the scope of the PCI-DSS obligations that come with handling card data. It is the same model as Stripe Checkout or the payment links of other processors. **Market:** Honduras. Usual currency: Honduran lempira (HNL, ISO numeric code `340`). ## 2. Architecture and flow ``` 1. Customer confirms an order on the merchant's site 2. Merchant server --POST /payments--> ROKI (with the secret key) 3. ROKI returns a payment in "pending" state with a checkout_url 4. Merchant stores the payment id locally and redirects the customer to checkout_url 5. Customer pays on ROKI's page (this is where card details are entered) 6. ROKI --signed webhook--> merchant server (authoritative confirmation) 7. Merchant marks the order as paid 8. Customer is redirected to success_url (NOT payment confirmation, see 9.3) ``` Steps 6 and 8 are independent and can arrive in any order - or step 8 may never happen at all if the customer closes the browser. **The truth about the charge lives in step 6 (or in a direct lookup), never in step 8.** ## 3. Base URL and versioning ``` https://aura.roki.systems/api/connect/v1 ``` The API is versioned under `/v1`. **The unversioned base was removed** and returns a routing 404; any integration written before August 2026 that points there is broken. There is no discovery, health or published-specification endpoint on the API. ## 4. Authentication One header, on every request: ```http Authorization: Bearer sk_live_xxxxxxxxxxxx Accept: application/json Content-Type: application/json <- POST only ``` - `sk_test_...` operates in **sandbox**; `sk_live_...` in **production**. The routes are identical: the environment is determined solely by the key prefix. - The secret key is **server-side only**. Never in a browser, a mobile app, a repository or a log. - A **publishable key** (`pk_...`) also exists in the portal. The payment-link flow does not use it today; it is intended for future browser-side integrations. **Authentication errors** (two distinct messages, useful for diagnosis): | Situation | HTTP | `message` | |---|---|---| | Header missing | 401 | `Encabezado Authorization ausente o invalido. Use: Bearer sk_test_... o Bearer sk_live_...` | | Key invalid or revoked | 401 | `Clave API invalida.` | ## 5. Credentials: obtaining and storing **Where they come from** (a manual merchant step - an agent cannot do this): `https://aura.roki.systems/merchant/connect/api-integration` -> pick the environment with the **Sandbox (prueba) / En vivo (produccion)** toggle -> copy the secret key. **The portal shows the full secret key exactly once** (on first setup, or after **Regenerar**). After that it is masked. If it was not saved, the only way out is regenerating - and the previous key stops working **immediately**, breaking any integration using it. **Where to store them in your project:** in a **configuration table read at runtime** (for example `integration_settings` with `roki_secret_key`, `roki_webhook_secret`, `roki_environment`), encrypted at rest if the project already encrypts secrets. That way, rotating a key means updating one record - ideally from the admin panel - with no code change and no redeploy. Environment variables are the fallback when the project has no configuration table. Never: in the repository, in browser code, or cached in code constants. ## 6. Response language Optional header `Accept-Language: es` or `en`. Spanish is the default. ```http Accept-Language: en ``` It affects API messages and validation errors: `"El campo amount es obligatorio."` becomes `"The amount field is required."` It does **not** affect routing errors (404 route not found, 405), which always arrive in English because the framework produces them before the application layer. ## 7. Creating a payment ```http POST /api/connect/v1/payments ``` ### 7.1 Required fields | Field | Type | Rule | |---|---|---| | `amount` | number | Minimum 0.01. **Decimal units, not cents** (150.50 = L 150.50). No maximum enforced by the API. | | `external_reference` | string | Your order id. Max 191. **Not unique** (see 11). | | `name` | string | Label the customer sees at checkout. Max 191. | ### 7.2 Optional fields | Field | Type | Notes | |---|---|---| | `currency_code` | string | ISO 4217 **numeric** (`"340"` = HNL). Defaults to the terminal's currency. Only currencies enabled on that terminal are accepted. | | `description` | string | Max 120. | | `metadata` | object | Your own key/value pairs. Must be an object: a string returns 422. See 10. | | `success_url` / `cancel_url` | string | Return URLs. Use HTTPS (see 12.4). | | `expires_at` | string | Honduras time (UTC-6), must be in the future. Accepts `YYYY-MM-DD HH:MM:SS` and ISO 8601. | | `sales_tax_type` | enum | `none` (default), `fixed`, `percentage`. | | `sales_tax_value` | number | Required when the type is not `none`. As a percentage, max 100. | | `tip_enabled` | boolean | See 8.2. | | `service_fee_enabled` | boolean | See 8.3. | | `reusable` | boolean | See 8.4. | | `customer` | object | Prefills the checkout: `name`, `email`, `phone`, `identity_number`. See 7.5. | | `lock_customer_fields` | boolean | Makes the prefilled fields read-only. See 7.5. | ### 7.3 Minimal example ```json { "amount": 150.00, "external_reference": "order-1001", "name": "Order #1001" } ``` ### 7.4 Response (201) ```json { "id": 706, "status": "pending", "name": "Order #1001", "description": null, "amount": 150, "reusable": false, "subtotal": 150, "sales_tax_amount": 0, "service_fee_amount": 0, "total": 150, "currency": "340", "currency_iso": "HNL", "external_reference": "order-1001", "metadata": {}, "checkout_url": "https://aura.roki.systems/pay/link/example1a2b3c", "transaction_id": null, "expires_at": "2026-08-09 18:00:00", "created_at": "2026-08-08 14:30:07", "paid_at": null } ``` **Store `id` in your database** - it is the only way to look the payment up again (there is no search by `external_reference`). Then **redirect the customer to `checkout_url`**. Two notes on that response. `transaction_id` is `null` until the payment is charged, and it is a **UUID string** - it changed type from an integer in an earlier version, and it is the identifier required by void, refund and receipts. And the `checkout_url` slug is a 12-character lowercase string: it carries no prefix, so never pattern-match a slug - redirect to the exact URL you were given. ### 7.5 Prefilling the customer The optional `customer` object prefills the hosted checkout with a known customer's details. Every field inside it is optional and independent: ```json { "customer": { "name": "Ahmed Khan", "email": "ahmed@example.com", "phone": "+50499999999", "identity_number": "0801199000000" }, "lock_customer_fields": true } ``` By default prefilled fields stay editable. With `lock_customer_fields: true`, every field you send with a non-empty value becomes read-only at checkout; fields you omit stay blank and editable even then. `identity_number` is worth sending: it is also the preferred identifier for looking up that customer's saved cards later (see section 23). Both fields **are echoed back in the response**, which matters more than it sounds: because this API silently ignores unknown fields, the echoed `customer` object is your only way to confirm from the response that the prefill actually applied. Check it rather than assuming the `201` means it worked. ```json "customer": { "name": "Ahmed Khan", "email": "ahmed@example.com", "phone": "+50499999999", "identity_number": "0801199000000" }, "lock_customer_fields": true ``` ## 8. Calculations: tax, tip and fee pass-through ### 8.1 Sales tax Computed on the subtotal only. ```json { "amount": 100, "sales_tax_type": "percentage", "sales_tax_value": 15 } -> subtotal 100, sales_tax_amount 15, total 115 ``` `fixed` adds an amount instead of a percentage. A percentage cannot exceed 100. ### 8.2 Tip `tip_enabled: true` **forces a choice between two modes**; omitting both returns 422 (`"Seleccione un tipo de propina o permita la seleccion del cliente en el checkout."`). **Fixed tip** - set by the merchant, reflected in the total at creation: ```json { "amount": 100, "tip_enabled": true, "tip_type": "percentage", "tip_value": 10 } -> total 110 ``` **Customer-selected** - the total at creation does **not** include a tip; the customer adds it at checkout: ```json { "amount": 100, "tip_enabled": true, "tip_customer_selectable": true, "tip_preset_percentages": [10, 15, 20], "tip_allow_custom": true, "tip_min_amount": 0, "tip_max_amount": 50 } -> total 100 at creation; the customer decides when paying ``` `tip_max_amount` **cannot exceed `amount`** - doing so returns 422. ### 8.3 Passing costs to the customer (`service_fee_enabled`) When enabled, ROKI's commission, the 3-D Secure charge and the tax on that commission are **passed on to the customer**. ROKI recomputes the total by **reverse calculation** so the merchant nets the requested `amount`. ```json { "amount": 100, "service_fee_enabled": true } -> subtotal 100, service_fee_amount 5.52, total 105.52 ``` **Never replicate this calculation on the integrator side.** The rates, the fixed charge and the threshold are per-merchant configuration and they change. **Read `service_fee_amount` and `total` from the response** - that is the only correct source. The fee is computed on the full base (amount plus tax plus fixed tip). **The field name is exactly `service_fee_enabled`.** The variants `service_fee`, `service_fee_type`/`service_fee_value` and `pass_fees_to_customer` are **silently ignored**: the API returns 201 and the payment is created **without** fee pass-through. See 12.1. ### 8.4 Reusable links (`reusable`) By default a link **is consumed once paid**: afterwards it shows "Enlace no disponible". With `reusable: true` the link accepts more than one charge - useful for fixed links shared on social media. ## 9. Retrieving a payment, and its lifecycle ```http GET /api/connect/v1/payments/{id} ``` Returns the same object as creation, with the current state. Use the key from the environment the payment was created in. ### 9.1 States | State | Meaning | |---|---| | `pending` | Created, not yet paid. | | `paid` | Successfully charged. `transaction_id` and `paid_at` appear. | | `partially_refunded` | Partially refunded. | | `refunded` | Fully refunded. | | `voided` | Voided before settlement; funds released. | | `expired` | The link expired unpaid. | | `disabled` | Disabled. | Payments with a refund history also carry `refunded_amount` and `refund_status` (`none` / `partial` / `full`). ### 9.2 Listing payments `GET /payments` returns the merchant's payments, newest first, paginated, and scoped to the key's merchant and environment - a sandbox key never sees production payments. ```bash curl "https://aura.roki.systems/api/connect/v1/payments?status=paid&per_page=50" \ -H "Authorization: Bearer sk_test_..." -H "Accept: application/json" ``` ```json { "data": [ { "id": 938, "status": "pending", "...": "the full payment object" } ], "meta": { "current_page": 1, "per_page": 20, "total": 47, "last_page": 3 } } ``` | Parameter | Effect | |---|---| | `per_page` | Page size, default 20. **`limit` is ignored** - only `per_page` works. | | `page` | 1-based. Stop when you reach `meta.last_page`. | | `status` | Filters by status. An unknown value returns **422**; it is not ignored. | | `external_reference` | Filters by your order id. Can match several, since it is not unique. | | `from` / `to` | Creation-date range, `YYYY-MM-DD`, Honduras time. | `meta.total` counts everything matching the filters, not the page - that is what you page against. **Still store the payment `id` at creation.** Listing makes reconciliation possible, but walking pages to find one payment is no substitute for having its id. ### 9.3 What confirms a payment and what does not **Confirms:** the `payment.approved` webhook, and a `GET /payments/{id}` returning `status: "paid"`. **Does not confirm:** the customer landing on `success_url`. That redirect is controlled by the customer's browser and anyone can navigate to the URL without paying. Treat it as a UX signal, never as proof of a charge. ## 10. Customer data: `metadata` The API has **no fields for customer data** and no way to prefill the checkout form: the customer enters their details on ROKI's page. To carry your own information, use `metadata`, which comes back untouched on retrieval and in every webhook: ```json "metadata": { "order_id": "1001", "customer_email": "customer@example.com", "branch": "SPS" } ``` It is a channel between your system and ROKI: **it is not shown to the customer**. Use it to reconcile without depending solely on your own database. ## 11. Idempotency and duplicates Optional but **always recommended** on creation: ```http Idempotency-Key: order-1001-create ``` Repeating the same request with the same key returns **the same payment**, with no duplicate. **Two verified behaviours that differ from the industry standard:** 1. **Same key plus a different body returns the original payment, with no error.** (Stripe would return 422.) The key wins and the body is ignored on replay. If a bug reuses a key for a different amount, you get a 201 describing a charge nobody asked for. -> **Derive the key from the order's content** (for example a hash of id, amount and currency), not only from its identifier. 2. **`external_reference` is not unique.** Without an `Idempotency-Key`, sending it twice creates two distinct payments. The idempotency key is the only duplicate protection. ## 12. Verified traps ### 12.1 Unknown fields are silently ignored A body with a misspelled field name returns **201 Created with no warning**, and the payment is created without that feature. ```json { "amount": 100, "service_fee": true } <- wrong name -> 201 Created, service_fee_amount: 0 <- the fee pass-through was NOT applied ``` This is the most dangerous failure mode of the API, because the result looks successful. It is especially dangerous for AI-generated code, which tends to write other gateways' field names (`card_token`, `payment_method`, `customer_id`, `amount_cents`) with full confidence. **Mandatory defence:** after creating a payment, **verify in the response** that the computed fields (`sales_tax_amount`, `service_fee_amount`, `total`, `reusable`) match what you expected. And validate your request body against the schema in `openapi.yaml`, which declares `additionalProperties: false` precisely to catch what the API lets through. ### 12.2 `amount` has no ceiling and coerces types The API accepts absurd amounts (11 digits) and numeric strings (`"150.00"`). Enforce a sane maximum before sending. ### 12.3 Two different kinds of 404 | Response | Meaning | |---|---| | `{"message":"Pago no encontrado."}` | The route exists; the payment does not. Wrong id, or an id from the other environment. | | `{"message":"The route ... could not be found."}` | **The route does not exist.** Malformed URL or a nonexistent endpoint. | Telling them apart saves hours: the second is never fixed by changing the id. ### 12.4 Timestamps are Honduras local time with no offset `expires_at`, `created_at` and `paid_at` come back as `YYYY-MM-DD HH:MM:SS` in **Honduras time (UTC-6)**, with no timezone marker in the string. Parsing them as UTC shifts every value by six hours - enough to make a future expiry look expired, or an order look paid before it was created. Attach the offset explicitly when parsing. ### 12.5 A customer-selected tip is not in the `total` you were given With `tip_customer_selectable`, the `total` returned at creation excludes the tip because the customer has not chosen it yet. The amount actually charged can therefore be higher than the total you stored. Reconcile against the webhook payload or a fresh lookup, never against the creation response. ### 12.6 `metadata` can come back as an empty array When no metadata was sent, the API may return `"metadata": []` instead of `{}`. Strict deserializers that expect an object will throw. Treat both shapes as "no metadata". ### 12.7 Do not hardcode the checkout domain from the official docs The `checkout_url` returned is on `aura.roki.systems/pay/link/{slug}`. Never hardcode or allow-list a checkout domain - always redirect to the exact `checkout_url` the API returned. ### 12.8 `success_url` accepts `http://` Although the official documentation requires HTTPS, the API accepts unencrypted URLs. Use HTTPS anyway - it is a return URL in a payment flow. ## 13. Void, refund and receipts These act on a **transaction**, identified by the `transaction_id` **UUID** from the payment response - never by the numeric payment `id`. That distinction matters because a reusable link can carry several transactions: voiding or refunding one customer's transaction leaves the others untouched. | Operation | Path | |---|---| | Void | `POST /payments/{transaction_id}/void` | | Refund | `POST /payments/{transaction_id}/refund` | | Receipt metadata | `GET /payments/{transaction_id}/receipt` | | Receipt PDF | `GET /payments/{transaction_id}/receipt/download` | ### 13.1 Passing the wrong identifier looks like a missing endpoint The route only matches the UUID format, so a numeric payment id produces a **routing 404**: ``` POST /payments/9e2b6a34-6f1d-4e2a-8c9b-2f7a1d4e5c6b/void -> the route matched POST /payments/873/void -> "The route ... could not be found." ``` If you see that message on void, refund or a receipt, you passed the payment id where the transaction UUID belongs. The endpoint is there. ### 13.2 Void or refund? | | Void | Refund | |---|---|---| | When | Same day, before settlement | After settlement | | Amount | Full only | Full or partial | | Speed | Immediate | 3-5 business days back to the card | | Customer sees | Never actually charged | Money returned | | Webhook | `payment.voided` | `payment.refunded` / `payment.partially_refunded` | | Typical case | Mistake, immediate cancel | Return, dispute, overcharge | Eligibility is governed by the transaction's configured time window, not only by settlement state. Attempting a refund too early can return `422` with a processor message such as `"Invalid transaction"` - that text comes from the processor and is not a response ROKI guarantees. Implement reversal as: **try void first; if it is rejected as not voidable or already settled, refund instead.** ### 13.3 Void ```http POST /api/connect/v1/payments/{transaction_id}/void { "reason": "Customer cancelled" } # optional, max 2000 chars ``` Returns the payment with `status: "voided"`. Rejections arrive as `422` under `errors.void`: already voided, already refunded (use refund), not in a voidable state, or already settled. **A void also sets the refund fields.** After voiding, the payment carries ```json { "status": "voided", "refunded_amount": 25, "refund_status": "full" } ``` even though nothing was refunded. So `refund_status === 'full'` does **not** mean "this was refunded" - it is equally true of a void. Branch on `status` (`voided` vs `refunded` vs `partially_refunded`) and treat the refund fields as the amount returned to the cardholder by any means, not as evidence of which operation was used. Reporting that counts refunds by `refund_status` will silently count voids as refunds, and the two are commercially different: a void never reaches the customer's statement, a refund does. The `payment.voided` webhook arrived about a second later on both transactions, carrying the same fields. ### 13.4 Refund ```http POST /api/connect/v1/payments/{transaction_id}/refund { "amount": 500.00, "reason": "Partial return" } ``` `amount` is required, minimum 0.01, up to the remaining refundable amount. A partial refund leaves `partially_refunded`; a full one sets `refunded`. `422` under `errors.amount` (below minimum, above remaining) or `errors.refund` (voided transaction, not refundable). Refund works the same for a transaction created by any mode, including an embedded checkout or a saved-card charge. There is no separate refund endpoint for token charges: use the `id` the charge returned as the `transaction_id` here. ### 13.5 Receipts `GET /payments/{transaction_id}/receipt` returns `payment_id`, `transaction_id` and a public `receipt_url`. Add `/download` for the PDF. Receipts survive a void or refund as long as the original charge still has a transaction. ### 13.6 A note on identifiers Two different handles, and mixing them is the most common mistake: - The numeric **payment `id`** is for `GET /payments/{id}`. - The **`transaction_id` UUID** is for void, refund and receipts. Both are worth persisting when a payment is charged. Listing (9.2) can recover a lost id, but only if you know what you are looking for. ## 14. Webhooks They are the authoritative confirmation of a charge. Without them, an integration depends on the customer returning to the site - which does not always happen. ### 14.1 Registration (a manual merchant step) At `https://aura.roki.systems/merchant/connect/webhooks`: 1. Pick the environment with the **Sandbox (prueba) / En vivo (produccion)** toggle - it **must match the `sk_` key the integration uses**. This is the most common mistake: registering the endpoint in production while developing with a test key, and receiving nothing. 2. Paste the public HTTPS URL of your endpoint. 3. **Guardar endpoint.** 4. Copy the **signing secret** the portal shows and store it in your project configuration. Separate URLs can be registered for sandbox and production, each with its own secret. The portal shows **"Entregas recientes"** (the last 10 delivery attempts) - the diagnostic tool when events are not arriving. ### 14.2 Events | Event | When | |---|---| | `payment.approved` | The customer paid successfully. Observed within ~2 seconds of payment. | | `payment.failed` | Card declined or payment error. | | `payment.expired` | The link expired before checkout completed. **Observed ~5 minutes after `expires_at`**, not at the instant: expiry is swept on a schedule. | | `payment.voided` | An approved payment was voided. | | `payment.refunded` | Full refund. | | `payment.partially_refunded` | Partial refund. | | `payment_method.saved` | The customer ticked "save my card"; carries the opaque `pm_*` (23.2). | The last three also arrive when the action is performed from the portal. ### 14.3 Event structure The exact shape of a delivery, headers included. **Headers** | Header | Example | What it is for | |---|---|---| | `ROKI-Signature` | `t=1786747980,v1=f90f1c...` | Verify it. See 14.4. | | `ROKI-Webhook-Event-Id` | `9a7a9698-e270-422a-ba8f-365e944248aa` | The event id, **also** in the header. Deduplicate on this without parsing the body. | | `ROKI-Webhook-Event-Type` | `payment.approved` | Route without parsing the body. | | `User-Agent` | `GuzzleHttp/7` | ROKI's own client. Do not filter on it. | **Body** ```json { "id": "9a7a9698-e270-422a-ba8f-365e944248aa", "type": "payment.approved", "created_at": "2026-08-14 16:52:58", "data": { "id": 1049, "status": "paid", "amount": 10, "subtotal": 10, "total": 10, "sales_tax_amount": 0, "service_fee_amount": 0, "currency": "340", "currency_iso": "HNL", "external_reference": "demo-hosted-mstjlm2w", "name": "Prueba modo 1 - guardar tarjeta", "description": null, "metadata": [], "customer": { "name": null, "email": "demo@roki.la", "phone": null, "identity_number": "0801199000000" }, "lock_customer_fields": false, "reusable": false, "checkout_url": "https://aura.roki.systems/pay/link/6ixekbmlwf8e", "transaction_id": "91b73300-38b3-48eb-b9d0-72941896b26e", "refunded_amount": 0, "refund_status": "none", "expires_at": "2026-08-14 17:22:35", "created_at": "2026-08-14 16:52:36", "paid_at": "2026-08-14 16:52:56" } } ``` **Three details that break integrations:** - The event `id` is a plain **UUID**, with no prefix, in both the body and the `ROKI-Webhook-Event-Id` header. Do not match on a prefix. - `data.transaction_id` is a **UUID string**, and is `null` until the payment is charged. Storing it in an integer column truncates it, which is what makes void and refund return a routing 404 later (13.6). See the schemas in 19.2. - `data` carries the **whole payment object**, identical in shape to `GET /payments/{id}` - not the subset the example suggests. `metadata` arrives as `[]` when empty, not `{}`. Refund events additionally include `refund_amount`, `refunded_at` and `refund_reason` inside `data`; `refunded_amount` and `refund_status` are present on every paid payment. ### 14.4 Signature verification (mandatory) Header `ROKI-Signature: t={timestamp},v1={hmac_sha256_hex}`. The HMAC is computed over **`timestamp + "." + raw_body`** using the signing secret: ``` expected = HMAC-SHA256(timestamp + "." + exact_raw_body, signing_secret) valid = constant_time_compare(expected, v1) ``` Three rules that break verification when ignored: 1. **Use the raw body, byte for byte.** If your framework parses the JSON and you re-serialize it, the signature will never match: spacing, key order or escaping change. 2. **Constant-time comparison** (`hash_equals`, `crypto.timingSafeEqual`, `hmac.compare_digest`). Never `==`. 3. Invalid signature -> respond **400**. Valid -> respond **200 fast** and process asynchronously; do not do heavy work before responding. ### 14.5 Idempotent processing The same event **can arrive more than once**. Store the event `id` and discard repeats, or make processing naturally idempotent (marking an already-paid order as paid must not charge twice or send two emails). The cheapest deduplication is on the `ROKI-Webhook-Event-Id` header: it is the same id that appears in the body, so a repeat can be discarded before the payload is even parsed. ROKI's retry policy is undocumented. Assume retries can happen and that delivery order is not guaranteed. ### 14.6 Fallback: direct lookup Webhooks can be lost (server down, deploy, network). Implement a fallback: if an order is still `pending` after X minutes, call `GET /payments/{id}`. This matters more than usual here because there is no list endpoint for bulk reconciliation. ## 15. Error catalogue | HTTP | When | Shape | |---|---|---| | **401** | Missing `Authorization` or invalid key | `{"message": "..."}` | | **404** (application) | The payment does not exist for that key | `{"message":"Pago no encontrado."}` | | **404** (routing) | The route does not exist | `{"message":"The route ... could not be found."}` - always English | | **405** | Wrong method | `{"message":"The GET method is not supported for route ... Supported methods: POST."}` | | **422** | Validation or business rule | `{"message":"...", "errors":{"field":["..."]}}` | A 422 `message` holds the first error, suffixed with `(and N more errors)` when there are several; `errors` groups them by field. **There are no machine-readable error codes**: do not build logic on string matching, because the text changes with `Accept-Language` and can be rewritten. Branch on the HTTP status and on the **keys** of the `errors` object, which are stable. ### 15.1 Common 422 messages | Message | Cause | |---|---| | `El campo amount es obligatorio.` | A required field is missing | | `El campo amount debe ser al menos 0.01.` | Amount below the minimum | | `La moneda seleccionada no esta disponible en su terminal de enlaces de pago.` | `currency_code` not enabled | | `La fecha de vencimiento debe ser en el futuro.` | `expires_at` in the past | | `El campo metadata debe ser un array.` | `metadata` sent as a string | | `Ingrese un monto o porcentaje de impuesto cuando el impuesto esta habilitado.` | `sales_tax_value` missing | | `El porcentaje no puede superar 100%.` | `sales_tax_value` above 100 in percentage mode | | `Seleccione un tipo de propina o permita la seleccion del cliente en el checkout.` | `tip_enabled` without a mode | | `Los limites de propina no pueden ser mayores que el monto del plan.` | `tip_max_amount` above `amount` | | `El entorno sandbox no esta configurado para enlaces de pago...` | Sandbox not provisioned for that merchant (16.2) | ## 16. Environments, testing and local development ### 16.1 Testing without risking money Creating a payment **charges nothing**: it stays `pending` until someone pays it. To test safely against production: a minimal amount (L 1.00) and a near-term `expires_at`, and do not pay it. The link expires on its own. Triggering 401 / 404 / 422 errors is equally harmless. ### 16.2 Sandbox test cards Use these **only** with `sk_test_` / `pk_test_` keys. Any future expiry date; name and email can be test values. | Brand | PAN | CVV | |---|---|---| | Visa | `4012000000020071` | 3 digits | | Visa | `4333333333332222` | 3 digits | | Amex | `343333333333335` | 4 digits | Prefer the two Visa numbers. No forced-decline or 3-D Secure scenario cards are published, so declines and authentication challenges still cannot be simulated deliberately - handle those states defensively even though you cannot exercise them on demand. ### 16.3 If sandbox rejects every creation An `sk_test_` key can authenticate correctly and still fail to create payments with: ``` 422 "El entorno sandbox no esta configurado para enlaces de pago. Contacte a soporte ROKI o use su clave API live." ``` That is not the key's fault and not the code's, and regenerating the key does not fix it: the sandbox terminal is not provisioned for that merchant. Ask ROKI to enable sandbox for the account. ### 16.4 Receiving webhooks in local development ROKI must reach a **public HTTPS** URL, so `localhost` will not work. Options: - A tunnel (ngrok, Cloudflare Tunnel), registering the generated URL in the portal under the **sandbox** environment. - To only inspect events without writing code, a temporary public receiver (webhook.site and similar), useful for seeing the real payload and headers. Remember to register the final URL before going to production: tunnel URLs change. ## 17. Quickstart ```bash # 1. Create a payment curl -X POST https://aura.roki.systems/api/connect/v1/payments \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Idempotency-Key: order-1001-create" \ -d '{ "amount": 150.00, "external_reference": "order-1001", "name": "Order #1001", "metadata": { "order_id": "1001" }, "success_url": "https://yoursite.com/thank-you", "cancel_url": "https://yoursite.com/cart", "expires_at": "2026-08-09 18:00:00" }' # -> 201 with "id" and "checkout_url": store the id, redirect the customer to checkout_url # 2. Check the status curl https://aura.roki.systems/api/connect/v1/payments/706 \ -H "Authorization: Bearer sk_test_..." \ -H "Accept: application/json" ``` ## 18. Mobile applications (iOS / Android) There is no ROKI mobile SDK. A mobile app integrates through the same hosted-checkout flow as the web, with three constraints that are specific to mobile and easy to get wrong. ### 18.1 The secret key never ships in the app A mobile binary is decompilable: strings can be extracted from any APK or IPA, and obfuscation only slows that down. A leaked `sk_live_` lets anyone create payments as the merchant and read every payment they have. **The app must never call the ROKI API directly.** The correct topology adds one hop: ``` Mobile app --> Merchant backend --POST /payments--> ROKI (holds the secret key) <-- returns only { payment_id, checkout_url } ``` The app receives the `checkout_url` and nothing else. The backend keeps the key, receives the webhook, and owns the order state. ### 18.2 Open the checkout in the system browser, not a WebView Use **SFSafariViewController** on iOS and **Chrome Custom Tabs** on Android. Do not use a plain `WKWebView` / `WebView`. Four reasons this matters on a payment page: 1. 3-D Secure challenges are rendered by the issuing bank, and many issuers block or misbehave in embedded WebViews. 2. Password managers and autofill do not work in a bare WebView, which raises card-entry friction and abandonment. 3. The customer cannot see the URL bar and padlock, so they cannot verify they are on a legitimate payment domain - a real trust problem when typing a card number. 4. The system browser shares its cookie jar and its security posture, so the checkout behaves the way ROKI tested it. ### 18.3 Returning to the app: Universal Links / App Links only **Verified against the live API:** `success_url` and `cancel_url` accept only `http`/`https` URLs. Custom schemes are rejected: | Value | Result | |---|---| | `https://yourapp.com/payment-done` | 201, accepted | | `myapp://payment/ok` | 422 on `success_url` | | `com.yourapp://checkout/done` | 422 on `success_url` | | `intent://payment#Intent;scheme=myapp;end` | 422 on `success_url` | So the return path must be an **https URL that the app claims** through Universal Links (iOS) or App Links (Android). The same URL should render a normal web page for customers who do not have the app installed. ### 18.4 Confirming the payment inside the app The rule from 9.3 is even more important on mobile, because the return trip is fragile: the customer may switch apps, lose connectivity, or dismiss the browser sheet before the redirect fires. **The app must never treat the return as confirmation, and must never ask ROKI directly.** Instead: 1. The backend receives the `payment.approved` webhook and updates the order. 2. The app polls its own backend (or receives a push notification from it) for the order status. 3. If the browser sheet is dismissed without a redirect, the app still polls - the payment may well have succeeded. ### 18.5 What does not exist today No mobile SDK, no native payment sheet, and no documented support for Apple Pay or Google Pay. The embedded card-fields SDK on the roadmap is browser-oriented; a mobile app that wants card entry inside its own UI has no supported path today and must use the hosted checkout. ## 19. 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: - **Unknown fields are rejected before the request is sent**, with the correct name suggested. `service_fee` is caught and pointed at `service_fee_enabled`, instead of being accepted with a `201` and ignored. - **`Idempotency-Key` is generated** where the specification requires it and you did not pass one. - **A 404 from void, refund or receipt says so**: it explains that the route wants the `transaction_id` UUID, not the numeric payment `id`. 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: - **The API silently ignores unknown fields.** A misspelled field name returns `201 Created` with no warning, and you get a payment without that feature. The client below therefore verifies the response against what it sent before it redirects anyone. - **Amounts are decimal units**, never cents. `150.50` means L 150.50. 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. ```php // 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(); }); ``` ```php // 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, ]; ``` ```php // 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 // 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. ```php // 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> $errors */ public function __construct(string $message, private readonly array $errors = []) { parent::__construct($message); } /** @return array> */ public function errors(): array { return $this->errors; } /** @return array */ 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 $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. ```php // 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 $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 $data * @return array */ 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. ```php // 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 $payload * @return array 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 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 */ 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. ```php 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: ```php // 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. ```php // 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. ```php // 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 */ 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. ```php // app/Services/Roki/ResponseGuard.php namespace App\Services\Roki; use Illuminate\Support\Facades\Log; final class ResponseGuard { /** * @param array $sent the body we posted * @param array $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); } } ``` ```php // 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']; } } ``` ```php // 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: ```php // 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.) ```php // routes/web.php Route::post('/webhooks/roki', RokiWebhookController::class) ->middleware('throttle:240,1') ->name('webhooks.roki'); ``` ```php // 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); } } ``` ```php // 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 $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]); } } ``` ```php // 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. ```php // 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 $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); } } } ``` ```php // 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. ```php // 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; } } ``` ```php // 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()`: ```php 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: - **Unknown fields are silently ignored.** A misspelled field returns `201` with no warning and a payment that quietly lacks the feature you asked for. The client whitelists the request fields and the checkout flow re-reads the computed fields from the response. - **`Idempotency-Key` semantics differ from the industry standard.** Replaying a key with a *different* body returns the *original* payment with no error. The key must therefore be derived from the order content, not from the order id. 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. ```sql -- 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. ```js // 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`. ```js // 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 {} ``` ```js // 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: ```js 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. ```js // 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]; } ``` ```js // 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. ```js // 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`: ```js app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })); ``` ```js // 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); } ``` ```js // 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: ```js // 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`. ```js // 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: ```js // 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: - **Unknown fields are silently ignored.** A misspelled field still returns `201 Created`, and the payment is created without that feature. The request model therefore declares `extra="forbid"`, and the response is verified against the request before anyone is redirected. - **Idempotency is key-first.** Replaying an `Idempotency-Key` with a *different* body returns the original payment with no error. The key must be derived from the order's content. - **Void, refund and receipts take the transaction UUID, not the payment id.** There is no list endpoint. Those reversal actions paths return a routing 404. Voids and refunds happen in the merchant portal and still arrive as webhooks, so reconciliation runs through `metadata` and the webhook channel. 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. ```python # 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. ```python # 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 ```python # 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. ```python # 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: ```python # 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 ```python 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. ```python # 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 ```python # 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. ```python # 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 ```python # 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 ```python @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. ```python # 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()) ``` ```python # 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) ) ``` ```python # 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. ```python # 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: ```python # 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: ```python 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. ```python # 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. ```python # 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`. ```sql 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 */ 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 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: - **Timeouts.** A checkout that hangs on a dead socket blocks a PHP-FPM worker. A total timeout plus a connect timeout is mandatory. - **The idempotency key is derived from the order's content, not from its id.** Verified behaviour: replaying a key with a *different* body returns the original payment **with no error**. If your key were `order-1001` and the customer edited the cart, the retry would silently reuse the old amount and charge the wrong total. Hashing the payload makes a changed order produce a different key. - **`errors` is read per field.** The `message` string is localized and only carries the first error; the **keys** of `errors` are stable and are what your logic branches on. ```php 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 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 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`. ```sql 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 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 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 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. ```sql 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 $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: - **The endpoint must be reachable without a session, a CSRF token, a login wall or a "Under construction" redirect.** ROKI posts machine-to-machine. - Register a URL and signing secret **per environment**. A sandbox event signed with the sandbox secret will not verify against the live secret. - `payment.voided`, `payment.refunded` and `payment.partially_refunded` arrive even when the action was performed by hand in the merchant portal, not only when you call the API. A handler that assumes "only my own calls change state" will be wrong - handle them either way. - Keep the handler small. Emails, PDF invoices and ERP calls belong in the `shop_jobs` cron worker. --- #### 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 > /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 - [ ] `roki_secret_key` and `roki_webhook_secret` live in `roki_settings`, not in any `.php` file. - [ ] The secret key never appears in HTML, JavaScript, logs or error pages. - [ ] Every create call sends an `Idempotency-Key` derived from the payload hash, not from the order id. - [ ] `roki_verify_payment()` runs before the redirect, and its failure blocks the redirect. - [ ] `total` and `service_fee_amount` are read from the response; the fee formula is not replicated. - [ ] The webhook verifies the HMAC over the raw body with `hash_equals`, returns 400 when it fails. - [ ] Event processing is deduplicated on the event `id`. - [ ] The polling cron is installed and its log is monitored. - [ ] Void, refund and receipts are called with the transaction UUID, never the numeric payment id. - [ ] No code path calls a payment-list endpoint - it does not exist. ### 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. ```php /** * 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}"); } ``` ```js // 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 ```php // 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. ```php // 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. ```php // 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: ```php /** * 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: ```php $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. ## 20. Reconciliation and operations runbook Void, refund, receipts and listing all exist now, so recovery is possible - but only if you decided in advance what to do. Define these paths before launch. **Persist enough to recover.** Store `roki_payment_id`, `external_reference`, `status`, `total`, `checkout_url`, `expires_at` and `last_event_id` on your order. Mirror your order id into `metadata` as well, so a payment inspected in the portal can be traced back without your database. **Sweep pending orders on a schedule.** Re-check stored ids for orders still `pending` past a threshold, and for any order past its `expires_at`. With the list endpoint you can also sweep the other way - `GET /payments?status=paid&from=...` - and catch payments that are settled at ROKI but still open in your system because a webhook was lost. **Handle terminal states arriving late.** `expired`, `voided`, `refunded` and `partially_refunded` can appear at any time - including from a human acting in the portal, with no API call from you. A handler that assumes "only my own calls change state" will be wrong. **Recover from a create that timed out.** If a creation request returns no response, do not create a second payment. Retry once with the **same** `Idempotency-Key`: if the first attempt reached ROKI, you get that payment back; if not, it is created now. **Alert on.** Repeated 401s (a rotated or broken key), 422 spikes (a deploy sending a bad payload), webhook signature failures (wrong secret, or raw-body handling broken by a middleware change), and orders paid in ROKI but still pending locally (webhooks not arriving). **Automate reversals, but keep the ordering.** Void applies before settlement and refunds the full amount immediately; refund applies after, and can be partial. Implement reversal as: try void, and if it is rejected as already settled or not voidable, refund instead. Both are also available to a human in the portal, and both emit webhooks either way. ## 21. Pre-production checklist - [ ] Credentials are read at runtime from configuration or the database, not from code. - [ ] The secret key does not appear in the repository, in the browser, or in logs. - [ ] `Idempotency-Key` is sent on every creation, **derived from the order's content**, not only its id. - [ ] The payment `id` is stored locally at creation (there is no lookup by `external_reference`). - [ ] The creation response is **verified**: `total`, `sales_tax_amount` and `service_fee_amount` are as expected. - [ ] The webhook endpoint verifies the HMAC signature **over the raw body**, with constant-time comparison. - [ ] The webhook responds 200 fast and processes asynchronously and idempotently by event `id`. - [ ] The webhook endpoint is registered in the portal, **in the environment matching the key**. - [ ] Landing on `success_url` does **not** mark the order as paid. - [ ] A direct-lookup fallback exists for orders left `pending`. - [ ] HTTP calls have timeouts (~30 s), with retries only on creation and the same idempotency key. - [ ] Reversal logic tries void first and falls back to refund when the transaction is already settled. - [ ] At least one real minimum-amount payment was tested end to end before going live. - [ ] Mobile only: the secret key is not in the app binary, the checkout opens in the system browser, return URLs are https Universal/App Links, and the app confirms via its own backend. ## 22. Mode 2 - Embedded components Card fields rendered by ROKI inside a **secure iframe on your own site**. The customer never leaves your page, and the card number never touches your code. ### 22.1 The flow ``` 1. Browser loads the ROKI SDK and mounts the component with pk_* only 2. Customer fills the card fields (inside ROKI's iframe) 3. Your "pay" button calls payment.submit() 4. The SDK returns a single-use tok_* to your page 5. Your page posts that tok_* to YOUR OWN backend 6. Your backend calls POST /api/connect/embed/confirm with sk_* + tok_* + amount 7. You get approved / declined / pending (3-D Secure) ``` **The amount is set by your backend at step 6, not by the browser.** Mounting needs no payment to exist beforehand. ### 22.2 Frontend ```html
``` SDK options: `paymentId` (optional), `customer`, `description`, `metadata`, `locale`, `saveCard`, `appearance.theme` (light/dark), `appearance.accentColor`. You cannot replace the iframe's HTML. ### 22.3 Backend confirm ```http POST https://aura.roki.systems/api/connect/embed/confirm Authorization: Bearer sk_test_... { "amount": 500.00, "currency_code": "HNL", "external_reference": "order-123", "payment_token": "tok_xxxx", "publishable_key": "pk_test_...", "success_redirect_url": "https://merchant.example/success", "failed_redirect_url": "https://merchant.example/failed" } ``` Note the base path: `/api/connect/embed`, **not** `/api/connect/v1`. **Both redirect URLs must be HTTPS. There is no local-development exception**, despite what the error message claims. Rejecting one returns: ``` 422 success_redirect_url_invalid "success_redirect_url debe ser una URL HTTPS valida (http solo se permite en desarrollo local)." ``` There is no local-development exception. What is accepted: | URL | Accepted | |---|---| | `http://localhost:4000/` | no | | `http://127.0.0.1/` | no | | `http://anything.test/ok` | no | | `https://localhost:4000/` | **yes** | | `https://your-site.com/gracias?order=1` | **yes** | So `http` is refused everywhere, including loopback, and query strings are fine. To develop locally you need TLS on localhost, a tunnel, or - simplest - point the two URLs at any HTTPS page you already control. They only matter when a 3-D Secure challenge sends the customer away and back; the ordinary approved/declined answer arrives in the `/confirm` response itself. The validation order helps when debugging: the body is validated before the token, so a `success_redirect_url_invalid` means the request never got as far as looking at your `tok_*`. ### 22.3.1 A pending 3-D Secure answer, transcribed from a real one When 3-D Secure needs to step in, the answer is not an error even though it reads like one. Three things in this single response will mislead an integrator: ```json HTTP 202 { "status": "pending", "code": "authentication_required", "error_code": "authentication_required", "error_message": "La autenticacion del pago aun esta en progreso. Consulte GET /payments/{id} o espere el webhook.", "transaction_id": "21e41243-4214-46b5-a29b-ff7966adb629", "amount": 25, "currency": "340", "authentication_url": "https://aura.roki.systems/connect/components/v1/confirm-challenge/21e41243-...?expires=1786751541&sig=a0d14de9..." } ``` 1. **The status code is `202`, not `200`.** Code written as `if (res.status !== 200) fail()` rejects a payment that is merely awaiting authentication - and the customer may still complete it, leaving the order failed while the money moves. 2. **It carries `error_code` and `error_message` although nothing failed.** `if (body.error_code)` is the natural check to write and it is wrong here. Branch on `status` instead: `approved`, `declined`, `pending`. 3. **The `authentication_url` is signed and expires.** The observed window was **15 minutes** (`expires` is a Unix timestamp, with a `sig` that seals it). Do not store it, email it, or render it later - send the customer there immediately. Load that URL as a **full-page redirect or a real popup**. The official documentation's example puts it in a 1x1 invisible iframe, where the challenge cannot be completed and the payment simply never advances. The `transaction_id` in this response is the one you will see again in the webhook and the one void and refund take. Persist it here, before the customer disappears into the challenge. ### 22.4 The three outcomes **`approved`** carries `transaction_id` plus `transaction_details` with the full financial breakdown - including `roki_commission`, `isv` and `expected_settlement`. Those are commercially sensitive: log them if you must, but never show them to the cardholder. **`declined`** carries the processor's own fields, capitalized as the processor sends them: ```json { "status": "declined", "IsoResponseCode": "05", "Errors": [{ "Code": "201", "Message": "..." }] } ``` Branch on `Errors[0].Code`, not on the message text. **`pending`** means 3-D Secure is required: ```json { "status": "pending", "authentication_url": "https://...", "transaction_id": "9e2b..." } ``` The final outcome arrives through the usual `payment.approved` / `payment.failed` webhooks - do not treat `pending` as a failure. **How you present `authentication_url` decides whether high-value payments work.** ROKI's tested flow **redirects the customer** to that URL. Some code samples instead append it as a 1x1 invisible iframe: ```js // DO NOT ship this as your only 3DS path. frame.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;border:0;'; ``` That is fine only for *frictionless* authentication, where the issuer approves silently. The moment the issuer requires a **challenge** - a code by SMS, the bank's app, a password - the customer sees nothing at all and the payment hangs forever. Challenges are most common on exactly the high-value transactions you least want to lose. Use one of these instead: - **Full-page redirect** to `authentication_url`, returning to your `success_redirect_url`. This is the flow ROKI tested end to end and the safest default. - **A visible modal iframe** sized for the challenge (roughly 400x600), if you want to keep the customer on your page. Handle the case where they close it without finishing. Whichever you pick, the webhook remains the source of truth: a customer who completes the challenge and then closes the tab still produces `payment.approved`, and your order must be fulfilled from that event, not from the redirect. ### 22.5 What to get right Keep the pay button disabled until `form_complete` reports the fields are valid, and disable it again while confirming - otherwise a double click produces two tokens. Treat `tok_*` as single-use: on a decline, either create a new payment or remount the component (a `reusable: true` payment makes the remount cleaner). And never put `sk_*` in the page: the token goes to your server, your server calls ROKI. ## 23. Mode 3A - Saved cards (tokenized payments) Charge a card the customer already saved, with no new card entry. This is what makes subscriptions and one-click repeat purchases possible. ### 23.1 The flow ``` 1. Customer pays once through mode 1 or mode 2 2. Customer explicitly ticks "save my card for next time" 3. ROKI stores the processor's own token - never the card number 4. Your backend receives the payment_method.saved webhook with an opaque pm_* (or looks it up later with GET /payment-methods) 5. Later, your backend charges that pm_* with sk_* 6. ROKI verifies the method belongs to you, in the right environment, and is still usable, then charges the stored token 7. You get the same payment.approved / payment.failed webhooks as any other payment ``` The card is saved **only** when the customer agrees. There is no way to save one silently. ### 23.1.0 Saved cards must be authorised for the account first **Before writing any code for this mode, confirm the merchant account has it enabled.** Card-on-file is not on by default: ROKI authorises it per merchant, manually. That is deliberate - storing a credential that can be charged without the customer present is a fraud and chargeback question, not a feature flag - and other gateways gate it the same way. What matters for an integration is how the refusal arrives, because it is not an error: | Situation | What you get | |---|---| | Sandbox not provisioned | `422 sandbox_terminal_unavailable`, saying so plainly (16.3) | | Card saving not authorised | **Nothing.** The payment returns `201`/`202`, the charge succeeds, and no card is stored | On an account without the module enabled, the refusal is silent: `saveCard: true` is accepted, the payment is approved, `GET /payment-methods` stays empty and no `payment_method.saved` event arrives. Nothing anywhere reports it. If you get that result, ask ROKI to enable card-on-file for the merchant before looking for a bug in your code. So do not debug your code. Check first: ```bash # Pay once with saveCard, then immediately: curl "https://aura.roki.systems/api/connect/v1/payment-methods?customer[identity_number]=0801199000000" \ -H "Authorization: Bearer sk_test_..." ``` An empty `data` after a successful save attempt means the account is not authorised, not that your request was wrong. Ask ROKI to enable card-on-file for the merchant, and only then build mode 3A. ### 23.1.1 How that agreement is actually enabled - verified 2026-08-14 The flow above says "the customer ticks a box" without saying where the box comes from. That matters, because the two modes differ and only one of them works today. **Mode 2 (embedded components): the merchant enables it.** The SDK accepts a `saveCard` option and translates it into `save_card=1` on the iframe URL. The checkbox the customer sees is **yours**; ROKI only receives the intent: ```js const payment = roki.createPaymentComponent({ customer: { identity_number: '0801199000000', email: 'buyer@example.com' }, saveCard: true, // el comercio habilita el guardado; la casilla la dibujas vos }); ``` Send `customer.identity_number`: that is the key `GET /payment-methods` looks the card up by. **Mode 1 (hosted checkout): nothing you send at creation turns it on.** There is no request field that makes the save option appear on the checkout page - it is an account authorisation, not a parameter (23.1.0). **What this means for an integration:** if you need saved cards, originate them from mode 2. Do not promise a customer that paying through a payment link will save their card. ### 23.2 Getting the `pm_*` From the webhook: ```json { "id": "evt_01HPM01", "type": "payment_method.saved", "created_at": "2026-08-12 12:05:00", "data": { "id": "pm_7k2n9xqf31ab", "card_brand": "Visa", "last_four": "4242", "exp_month": 11, "exp_year": 2027, "is_default": true } } ``` Or on demand: ```bash curl -G https://aura.roki.systems/api/connect/v1/payment-methods \ -H "Authorization: Bearer sk_test_..." \ --data-urlencode "customer[identity_number]=0801199012345" ``` Identify the customer with `customer[identity_number]` (preferred) or `customer[email]` - the same identifiers you send when creating payments. **A customer with no saved cards is a normal `200` with `{"data": []}`, not a 404.** ### 23.3 Charging ```http POST /api/connect/v1/payment-methods/pm_7k2n9xqf31ab/charge Authorization: Bearer sk_test_... Idempotency-Key: order-2002-charge { "amount": 500.00, "currency_code": "HNL", "external_reference": "order-2002" } ``` **`Idempotency-Key` is mandatory here**, not merely recommended as it is on payment creation. Omitting it returns `422`. Use a unique key per charge attempt so a network retry can never charge twice. Note `currency_code` takes the alphabetic code (`"HNL"`) on this endpoint, while payment creation takes the numeric one (`"340"`). `POST /payments/token-charge` is an equivalent alias that takes the saved card in the body as `payment_token`, which suits subscription renewals: ```json { "payment_token": "pm_7k2n9xqf31ab", "amount": 100.00, "currency_code": "HNL", "external_reference": "sub-aug-2026" } ``` **Never call either endpoint from a browser.** Both need `sk_*`. ### 23.4 Reversing and revoking The charge response returns an `id` - that is the **transaction UUID**. Use it with the ordinary `/payments/{transaction_id}/void` and `/refund` endpoints from section 13. There is no separate reversal path for token charges. To remove a saved card: ```bash curl -X DELETE https://aura.roki.systems/api/connect/v1/payment-methods/pm_7k2n9xqf31ab \ -H "Authorization: Bearer sk_test_..." ``` A revoked method fails later charges with a distinct error rather than silently succeeding. ### 23.5 What to think about before enabling it A saved card charged without the customer present is a different risk profile from a checkout they just completed. Before shipping recurring billing: define what happens when a card expires or is reissued, decide how many times you retry a declined renewal and after which decline codes you stop, and give the customer a way to see and remove their saved cards. None of that is enforced by the API. ## 24. Troubleshooting | Symptom | Likely cause | What to do | |---|---|---| | 401 on every call | Key mistyped, regenerated, or with stray whitespace | Regenerate in the portal and update the configuration | | `The route ... could not be found` | `/v1` missing from the base, or a numeric payment id where a transaction UUID belongs | Fix the URL; void/refund/receipt are keyed by the transaction UUID (13.1) | | 404 `Pago no encontrado` | Id from the other environment (created with `sk_test_`, queried with `sk_live_` or vice versa) | Use the key from the same environment | | 422 about `currency_code` in sandbox | Sandbox not provisioned for the merchant | 16.2 - ask ROKI to enable it | | Payment created but without tax/tip/fee | Field name misspelled and silently ignored | 12.1 - compare against `openapi.yaml` and inspect the response | | No webhooks arriving | Endpoint registered in the wrong environment, URL not public, or not HTTPS | Check the sandbox/production toggle and "Entregas recientes" in the portal | | Webhooks arrive but the signature fails | Signing re-serialized JSON instead of the raw body | 14.4, rule 1 | | Paid orders stuck as pending | Relying on `success_url` to confirm | 9.3 - confirm via webhook or lookup | | Duplicate payments | Missing `Idempotency-Key` (`external_reference` is not unique) | 11 | | Checkout link dead after one payment | `reusable` defaults to false and the link is consumed | 8.4 | | Expiry looks wrong by six hours | Timestamps parsed as UTC instead of UTC-6 | 12.4 |