omise

Version, currently main branch1 version
  • main branchlatestAug 4, 2026

github.com/hostari/omise-crystal

No description declared in shard.yml.

0 stars
0 dependents
License: MIT

Installation

# Add this to your shard.yml
dependencies:
  omise:
    github: hostari/omise-crystal
    branch: main

main is a branch, not a release, so this tracks it rather than pinning a version.

Then run:

shards install

shard.yml

Crystal
~> 1.19.2
License
MIT
Author
Xavier Luis Ablaza

Dependencies

This version declares no dependencies.

README

Omise for Crystal

A minimal, dependency-free Crystal client for Omise Payment Links+ hosted checkout and core-API card, PromptPay, and TrueMoney Wallet JumpApp charges. The core API is pinned to 2019-05-29; Payment Links+ uses its separately documented API. Supports Crystal ~> 1.19.2.

Installation

Add the shard to your application's shard.yml:

dependencies:
  omise:
    path: ../omise-crystal

Adjust the path to the local checkout relative to your application's shard.yml.

Then run shards install and require it:

require "omise"

Create a hosted Checkout Session

Hosted checkout uses Omise Payment Links+, not the legacy core API's /links endpoint. Payment Links+ has its own dashboard, API key, hostname, team ID, and template ID. Retrieve these from the Payment Links+ dashboard and its Settings > API Keys and Team Settings pages.

checkout = Omise::PaymentLinksClient.new(
  api_key: ENV["OMISE_PAYMENT_LINKS_API_KEY"]
)

session = checkout.create_checkout_session(
  template_id: ENV["OMISE_PAYMENT_LINKS_TEMPLATE_ID"].to_i64,
  team_id: ENV["OMISE_PAYMENT_LINKS_TEAM_ID"].to_i64,
  amount: 10_000_i64, # THB 100.00 in minor units
  name: "Order #42",
  currency: "THB",
  multiple_usage: false,
  features: {"Type" => "Game server"}, # only when required by the template
  custom_webhook: "https://merchant.example/omise-webhook",
  return_url: "https://merchant.example/payment-status"
)

# In your framework's authenticated, CSRF-protected POST action:
response.redirect session.url

The client sends the Payment Links+ API key directly in Authorization to POST https://linksplus-api.omise.co/external/links, as required by Omise's documentation. Do not use OMISE_PUBLIC_KEY, OMISE_SECRET_KEY, or a webhook secret here; those are different credentials.

The returned Omise::CheckoutSession exposes the documented Link fields, including id, team_id, name, amount, currency, multiple_usage, transaction_id, and transaction_url. session.url aliases transaction_url. The client checks the request-correlated response fields and only accepts an active HTTPS checkout URL on the exact linksplus.omise.co host with no userinfo or nonstandard port.

Payment Links+ creation requires an existing template. Any feature fields configured on that template are conditionally required and must be supplied through features. If currency or multiple_usage is omitted, Omise uses the template's setting. custom_webhook and return_url are optional; as an intentional client-side security policy, this shard requires both to use HTTPS without userinfo or a nonstandard port. Omise does not document an idempotency-key parameter for this endpoint, so applications should persist the resulting Link ID against their order and avoid retrying an ambiguous request blindly.

The shard's included Omise::Event and Omise::Verifier handle core Omise PromptPay charge completion only. Payment Links+ webhooks use a separate integration surface. Authenticate and reconcile those events according to your Payment Links+ dashboard configuration before fulfillment.

Create a card charge

Card details must be tokenized in the customer's browser with Omise.js. This shard intentionally accepts only the resulting single-use tokn_... token; never send or store a PAN, expiry date, or security code on your Crystal backend.

client = Omise::Client.new(
  public_key: ENV["OMISE_PUBLIC_KEY"],
  secret_key: ENV["OMISE_SECRET_KEY"]
)

charge = client.create_card_charge(
  token: browser_supplied_token,
  amount: 10_000_i64, # minor units
  currency: "THB",   # normalized to uppercase; defaults to THB
  description: "Order #42",
  metadata: {"order_id" => "42"},
  return_uri: "https://merchant.example/payments/return"
)

case charge.result
when Omise::ChargeResult::Successful
  # Wait for/reconcile the authoritative charge.complete event before fulfillment.
when Omise::ChargeResult::AuthorizationRequired
  response.redirect charge.authorization_uri.not_nil!.to_s
when Omise::ChargeResult::Failed
  Log.warn { "card charge failed: #{charge.failure_code}" } # never log tokens or keys
else
  # Do not fulfill an indeterminate/pending charge.
end

create_card_charge posts amount, currency, and card to the core /charges endpoint using secret-key Basic authentication. description, metadata[...], and return_uri are optional. Input is validated before transport. Return URLs must use HTTPS, except that HTTP is accepted for the loopback development hosts localhost, 127.0.0.1, and ::1. A returned authorization URL is accepted only when it is HTTPS on the exact api.omise.co host, uses the default port, has no userinfo, and has a non-root /payments/... path.

Omise::Charge supports nullable source, card, customer/schedule IDs, lifecycle timestamps, and string metadata fields. Use source_type?, source_of_type, source_of_type!, promptpay?, card?, and the typed instrument helpers rather than assuming either payment instrument is present. Lifecycle helpers include successful?, authorization_required?, failed?, expired?, reversed?, terminal?, and the typed result; failure details are available as failure_code and failure_message.

Save a card and charge renewals

Convert an unused browser token into an Omise customer before it is consumed by a direct charge:

customer = client.create_customer(
  token: browser_supplied_token,
  email: current_user.email,
  metadata: {"user_id" => current_user.id.to_s}
)

first_charge = client.create_customer_card_charge(
  customer_id: customer.id,
  amount: 10_000_i64,
  return_uri: "https://merchant.example/payments/card-return",
  metadata: {"invoice_id" => invoice.id.to_s}
)

For an unattended renewal, send the stored first-charge ID so the provider can associate the recurring cycle:

renewal = client.create_recurring_card_charge(
  customer_id: customer.id,
  amount: 10_000_i64,
  first_charge: stored_first_charge_id,
  metadata: {"invoice_id" => invoice.id.to_s}
)

The renewal helper always sends transaction_indicator=MIT and defaults recurring_reason to standing_order; attended customer-card charges default to CIT. A specific reusable card_id can be supplied, or omitted to use the customer's default card. Use add_customer_card with a fresh browser token to add a replacement card. Store only provider customer/card identifiers and display-safe card fields—never raw card data.

Create a PromptPay charge

Amounts are integers in minor currency units (satang). PromptPay charges use THB.

client = Omise::Client.new(
  public_key: ENV["OMISE_PUBLIC_KEY"],
  secret_key: ENV["OMISE_SECRET_KEY"]
)

charge = client.create_promptpay_charge(
  10_000_i64,                         # THB 100.00
  description: "Order #42",
  metadata: {"order_id" => "42"}
)

puts charge.id
puts charge.promptpay_qr_uri

The client first creates a source with the public key, then creates its charge with the secret key. promptpay_qr_uri reads charge.source.scannable_code.image.download_uri and raises Omise::ResponseError if Omise does not return that path.

Create a TrueMoney JumpApp charge

TrueMoney Wallet App Redirection is available for Thailand in THB. Its documented limits are THB 20 through THB 50,000 (2,000 through 5,000,000 minor units). The client creates the source and charge in one secret-key request and validates the provider redirect before exposing it:

charge = client.create_truemoney_jumpapp_charge(
  amount: 10_000_i64,
  return_uri: "https://merchant.example/payments/truemoney-return",
  description: "Order #42",
  metadata: {"order_id" => "42"}
)

response.redirect charge.authorization_uri.not_nil!.to_s

Only exact HTTPS api.omise.co and pay.omise.co authorization hosts are accepted. The authorization session is short-lived, and returning from the wallet app is not proof of payment. Reconcile a signed webhook—or explicitly retrieve the charge on return—against the stored order. Omise::TrueMoneyJumpAppVerifier retrieves the authoritative charge and accepts successful, failed, or expired terminal outcomes.

Use a different HTTPS API origin for testing if needed. A base path is preserved and API paths are joined beneath it:

client = Omise::Client.new("pkey", "skey", api_base: "https://example.test/v1")

The client rejects bases without a host and bases containing userinfo, a query, or a fragment. HTTPS is mandatory by default. Plain HTTP can only be enabled explicitly for loopback test servers:

client = Omise::Client.new(
  "pkey",
  "skey",
  api_base: "http://127.0.0.1:3000",
  allow_insecure_api_base: true
)

Transport limits

The default Omise::HTTPTransport uses finite connect, read, and write timeouts of 5, 30, and 30 seconds. It caps response bodies at 1 MiB and raises Omise::TransportError if that cap is exceeded. Applications can tune these limits while retaining the production transport:

transport = Omise::HTTPTransport.new(
  connect_timeout: 3.seconds,
  read_timeout: 15.seconds,
  write_timeout: 15.seconds,
  max_response_body_bytes: 512_i64 * 1024
)
client = Omise::Client.new("pkey", "skey", transport: transport)

A custom Omise::Transport can still be injected for tests or specialized networking. The response cap protects API responses; it does not limit incoming webhook request bodies. Configure a request-body limit in your HTTP server or reverse proxy before reading webhook JSON.

Verify a webhook

Verify the signature over the unmodified request body, parse it, then retrieve and verify the charge through Omise:

raw_body = request.body.not_nil!.gets_to_end
Omise::WebhookSignature.verify!(
  raw_body,
  request.headers["Omise-Signature"],
  request.headers["Omise-Signature-Timestamp"],
  ENV["OMISE_WEBHOOK_SECRET"]
)
event = Omise::Event.parse(raw_body)
verified_charge = Omise::Verifier.new(client).verify(event) # PromptPay
# Or, for a card order:
verified_card_charge = Omise::CardVerifier.new(client).verify(event)
# Or, for TrueMoney Wallet:
verified_truemoney_charge = Omise::TrueMoneyJumpAppVerifier.new(client).verify(event)

# Only now transition your expected order, idempotently.

Omise sends the affected charge directly in the event's data field. Both event.data and the convenience method event.charge return that charge.

Omise::Verifier requires charge.complete, requires PromptPay, retrieves GET /charges/:id with the secret key, compares charge ID, amount, currency, and source ID with the webhook, then requires the retrieved charge to have paid == true and status == "successful". Omise::CardVerifier accepts charge.create (needed for successful non-3DS cards) and charge.complete, retrieves card events authoritatively, and compares the card ID. It accepts either a successful or failed terminal card charge so applications can reconcile 3DS declines and unknown outcomes; nonterminal card charges are rejected.

Webhook security and idempotency

When webhook signing is enabled in the Omise dashboard, Omise::WebhookSignature.verify! validates the current HMAC-SHA256 protocol, accepts comma-separated signatures during secret rotation, and rejects timestamps outside a five-minute replay window by default. Pass the exact raw request bytes; parsing or re-serializing first changes the signed payload. The dashboard secret is expected in its documented Base64 form.

Always retrieve the charge from Omise before trusting paid or status; both Omise::Verifier and Omise::CardVerifier do this. Signature verification authenticates delivery, while retrieval confirms the provider's current state. Neither proves that the charge belongs to the order named by your route or session.

Your application must additionally:

  • for PromptPay, reconcile the verified charge ID, source ID, exact amount, and THB currency against the stored order;
  • for cards, reconcile the verified charge ID, expected amount and currency, and the stored order/customer association—matching a provider card ID does not establish order ownership;
  • for TrueMoney, reconcile the charge ID, source ID, exact amount, and THB currency against the stored order;
  • reject an otherwise valid charge that belongs to another customer or order;
  • store processed event and/or charge IDs under a unique constraint;
  • make fulfillment transactional and idempotent because webhook deliveries can be duplicated or reordered;
  • return a successful webhook response only after the durable transition is complete (or safely queued).

Errors

  • Omise::APIError retains the HTTP status, raw body, and Omise code, location, and message fields when available.
  • Omise::TransportError reports transport safety failures such as an oversized API response.
  • Omise::ResponseError reports malformed or semantically inconsistent API/webhook JSON or a missing QR URI.
  • Omise::VerificationError reports rejected webhook verification.
  • Omise::SignatureVerificationError reports missing, malformed, stale, or mismatched webhook signatures.

Development

mise x crystal@1.19.2 -- crystal spec

No runtime or test shards are required.

License

MIT © Xavier Luis Ablaza. See LICENSE.