omise-crystal

Omise PromptPay for Crystal

A minimal, dependency-free Crystal client for creating and verifying Omise PromptPay charges. It pins the Omise API contract to 2019-05-29 and 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 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.

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

Parse the raw webhook body, then retrieve and verify the charge through Omise:

event = Omise::Event.parse(request.body.not_nil!.gets_to_end)
verified_charge = Omise::Verifier.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.

The 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".

Webhook security and idempotency

Omise webhooks do not carry a cryptographic signature. Never trust the webhook body by itself. Always retrieve the charge from Omise before trusting paid or status; Omise::Verifier does this. Retrieval proves what Omise currently reports, but does not prove that the charge belongs to the order named by your route or session.

Your application must additionally:

  • reconcile the verified charge ID, source ID, exact amount, and THB currency against values stored for the expected order when the charge was created;
  • 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.

Development

mise x crystal@1.19.2 -- crystal spec

No runtime or test shards are required.

License

MIT © Xavier Luis Ablaza. See LICENSE.

Repository

omise-crystal

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 1 hour ago
  • August 2, 2026
License

MIT License

Links
Synced at

Sun, 02 Aug 2026 15:17:08 GMT

Languages