Ruby SDK
lunixi-sdk is a server-side Ruby client for Lunixi. It handles OpenSSL Ed25519 request signing, synchronized token caching, compact JavaScript-compatible JSON, safe retries and raw-body webhook verification. It works with Rails, Sinatra, Rack and plain Ruby. The core gem has no framework dependency; the executable Rails integration is included in the source repository.
Requirements and source installation
Ruby 3.3+, Bundler and an OpenSSL build supporting Ed25519 are required. The Rails example uses Rails 8.1.
Version 0.1.2 is distributed from source. RubyGems publication is a separate release step; a registry install is not assumed here.
git clone --branch v0.1.2 --depth 1 https://github.com/Lunixi-Payment/lunixi-ruby.git
cd lunixi-ruby
bundle install
gem build lunixi-sdk.gemspec
gem install ./lunixi-sdk-0.1.2.gemSource: published v0.1.2 tag (opens in a new tab). An application may also use a reviewed local checkout with gem 'lunixi-sdk', path: '/absolute/path/to/lunixi-ruby', or the exact tag in its Gemfile:
gem 'lunixi-sdk', git: 'https://github.com/Lunixi-Payment/lunixi-ruby.git', tag: 'v0.1.2'Keep Gemfile.lock so Bundler retains the reviewed source commit. This uses GitHub source, not a RubyGems registry publication.
Client and keys
require 'lunixi'
lunixi = Lunixi::Client.new(
base_url: ENV.fetch('LUNIXI_BASE_URL'),
key_id: ENV.fetch('LUNIXI_KEY_ID'),
private_key: File.read(ENV.fetch('LUNIXI_PRIVATE_KEY_PATH')),
timeout: 30,
max_retries: 2
)base_url must be an HTTPS origin such as https://api-gateway.lunixi.com, without credentials, a path, query or fragment. The key determines TEST/LIVE behavior. Create one client per merchant connection and reuse it across requests; the token cache is thread-safe.
Generate an Ed25519 key on your server:
umask 077
openssl genpkey -algorithm ED25519 -out lunixi-private.pem
openssl pkey -in lunixi-private.pem -pubout -out lunixi-public.pemRegister the public key in the Lunixi dashboard and use the resulting key id. Keep the private key outside source control and the public web root. Supported private-key encodings are PKCS#8 PEM, base64 PKCS#8 DER, or a base64 32-byte seed / 64-byte secret. See Authentication.
Payment intent
Persist the payment attempt and its UUID idempotency key before the first request. Calculate prices on your server, not from a browser total.
result = lunixi.payments.create_intent(
{
amount: 10_050,
currency: 'TRY',
orderId: order.public_id,
callbackUrl: 'https://merchant.example/payments/return',
basketItems: [{ id: 'item-1', name: 'Product', itemType: 'PHYSICAL', price: 10_050 }]
},
idempotency_key: order.persisted_payment_attempt_key
)
intent_id = result.fetch('data').fetch('paymentId')
checkout_token = result.fetch('data').fetch('token')The existing form opens on the merchant website by default. Optional hosted checkout uses the exact validated backend paymentPageUrl, independently of the embedded token. Encrypt the token and URL; both modes share the merchant-enabled form methods. Never collect PAN/CVV.
An intent/token, HTTP 2xx, return URL or intermediate split state does not prove capture. Correlate a verified webhook with the persisted order and authenticate a payment status read; confirm final status, amount and currency before fulfillment.
Amounts must be positive integers within JavaScript's safe integer range. Use only amount at the request root; legacy root amount fields are rejected even when null. Basket prices and split leg amounts must equal the relevant total. Intent installment is rejected because installments are selected in the checkout form. preauth: true requests authorization; capture is a separate operation.
Available helpers
| Operation | API |
|---|---|
| Create an intent | lunixi.payments.create_intent(body, idempotency_key:) |
| Read/list payments | lunixi.payments.get(id), lunixi.payments.list(**filters) |
| Merchant methods | lunixi.payments.methods(payment_method: nil) |
| Capture | lunixi.payments.capture(id, idempotency_key:, amount: nil) |
| Full/partial refund | lunixi.payments.refund(id, idempotency_key:, amount: nil, reason: nil) |
| Void | lunixi.payments.void(id, idempotency_key:) |
| Split payment | lunixi.payments.split(body, idempotency_key:) |
| Split status/refund | lunixi.payments.get_split(id), lunixi.payments.refund_split(id, amount:, idempotency_key:, reason: nil) |
| Fraud | lunixi.fraud.evaluate(body), lunixi.fraud.record_event(body, idempotency_key:) |
| Advanced signed HTTP | lunixi.request(method, path, body: nil, query: {}, idempotency_key: nil, signed: true, binary: false) |
Capture/refund amount: nil asks the gateway to use the remaining amount. False, zero, string and fractional amounts are rejected. Persist a separate operation key for each capture, refund or void. Running split HTTP 400 protocol responses are returned only for documented running states; they remain pending, not paid. Response envelopes are preserved.
The advanced transport supports other documented gateway routes without a second HTTP implementation:
links = lunixi.request('GET', '/api/v1/payments/links', query: { limit: 20 })Payment, fraud and webhooks have explicit helpers. Other services require their published REST schema; this version does not provide a separate typed client for every product.
Webhooks
Get the exact raw request bytes before parsing JSON. Pass the Lunixi signature headers as strings:
verifier = Lunixi::WebhookVerifier.new(
secret: ENV.fetch('LUNIXI_WEBHOOK_SECRET'),
previous_secrets: []
)
verifier.process(raw_request_bytes, signature_headers, store: durable_transactional_store) do |event|
# Reconcile event.data with the persisted intent/order and payment status.
# Commit the business change in the same transaction as the event receipt.
endThe verifier requires v2 HMAC-SHA256 over eventId.timestamp.rawBody, uses constant-time comparison, checks signed id/type consistency, rejects duplicate JSON keys, and accepts timestamps within 300 seconds by default, including future skew. Up to two previous secrets support rotation. Header names are case-insensitive; duplicate headers are rejected.
Verification alone does not record receipt. Your store implements process_once(id:, digest:) { … }: insert a unique event receipt and update the order in one database transaction, return :processed / :duplicate, and raise Lunixi::ReplayConflict for the same id with different bytes. Handler failure must roll back the receipt, allowing redelivery. Retain receipts across process restarts. The Rails example includes a real database store and unique (connection_id, event_id) index.
Errors and retries
Lunixi::Error is the base class. ConfigurationError, SignatureError, TransportError, RedirectError, WebhookError / ReplayConflict, and ApiError subclasses distinguish failure types. API errors expose status, safe code / request_id, and optional retry_after; remote payloads and error text are omitted.
GET/HEAD and idempotency-keyed mutations may retry network errors or HTTP 429/502/503/504, default two retries and maximum three. Retries preserve the body and key while refreshing the nonce/signature. A safe 401 refreshes the token once. Unkeyed mutations are not retried. An uncertain timeout requires reconciliation with the original key, never a new charge key.
TLS peer/hostname verification is mandatory; redirects and path/credential injection are rejected. The request limit is 1 MiB; response limit is 8 MiB. transport:, clock: and sleeper: can be injected for deterministic testing. A custom transport returns Lunixi::Response.new(status:, headers:, body:) and must preserve TLS, redirect and thread-safety guarantees. ca_file: accepts an explicit trust bundle without disabling verification. Ambient proxy credentials are not inherited.
Testing and Rails
bundle exec rake test rails:test
bundle exec rubocop
bundle exec bundle-audit check --update
bundle exec brakeman --force --rails8 --path samples/rails --no-pagerThe suite covers cryptographic vectors, tampering, replay/rotation, exact signed bytes, money, concurrent token caching, retries, real local TLS and Rails database transactions. Node.js supplies an independent JSON.stringify comparison. Tests use synthetic fixtures and move no funds; a registered TEST merchant is needed for a live acceptance run. See the Rails guide and repository security policy (opens in a new tab).
On-site form and optional hosted checkout
The Rails example defaults to LUNIXI_CHECKOUT_MODE=embedded. It renders the existing Lunixi form on the merchant page using the fixed official CDN, HTML-escaped JSON data attributes and a per-response CSP nonce. Lunixi::Checkout.browser_config(token) provides only the official object/mount configuration. No gateway HTML or merchant secrets are rendered.
Set LUNIXI_CHECKOUT_MODE=hosted explicitly for optional redirect. Lunixi::Checkout.payment_page_url(data.fetch('paymentPageUrl')) and the Rails redirect wrapper validate the exact backend URL: HTTPS pay.lunixi.com/payment/ followed by a 16–4096-character ASCII opaque value, with no userinfo, port, query, fragment or controls. It can differ from the embedded token. Do not construct a URL from that token. Both modes expose the same merchant-enabled form methods, multi-card, installments and insurance; these features do not require redirecting. The existing fixed-total single-intent settlement limitation still applies.
Run rails db:migrate before deploying 0.1.2. New request/mode/callback and start time are persisted before network I/O; request/token/backend URL use Active Record Encryption. Retries reuse the saved body and key and stop financial dispatch after 23 hours. Existing rows are marked legacy hosted; unknown legacy requests or missing backend URLs remain reconciliation-only, without rewriting origin, resetting a key or inventing a URL. Global mode changes affect new reservations; existing requests retain their saved presentation. CSRF/demo authorization, raw webhooks, native receipt transactions and the bounded 3DS/split return adapter remain unchanged.
Focused 0.1.2 verification: 21 Rails form/return/URL security tests with 117 assertions, 2 SDK helper tests with 5 assertions, RuboCop and Brakeman (one exact documented strict-URL redirect false positive). Prior full 0.1.1 results remain the baseline; live/merchant acceptance is deferred.