BMLConnect
Ruby gem for the Bank of Maldives Connect API
Use this gem to interact with your Bank of Maldives Connect API:
- 💳 Transactions
- 👤 Customers
Planned — specified against BML's published contract, not yet implemented. See Roadmap:
- 🔐 Stored card tokens
- 🔁 Charging a stored card
Installation
Add this line to your application's Gemfile:
gem 'bml_connect'And then execute:
$ bundle install
Or install it yourself as:
$ gem install bml_connect
Usage
First get your API key and App ID from Merchant Portal for production or sandbox.
For production client:
require 'bml_connect`
client = BMLConnect::Client.new(api_key: '<your-api-key>', app_id: '<your-app-id>')For sandbox client:
require 'bml_connect`
client = BMLConnect::Client.new(api_key: '<your-api-key>', app_id: '<your-app-id>', mode: 'sandbox')In Ruby on Rails, to configure globally, add an intilializer bml_connect.rb:
module BMLConnect
class Client
BML_API_KEY = ENV['BML_MPG_KEY']
BML_APP_ID = ENV['BML_MPG_APP_ID']
BML_MODE = ENV['BML_MPG_MODE']
end
endclient = BMLConnect::Client.newAPI Operations
Deprecation.
transactions.createposts to the legacy, undocumented-but-livePOST /public/transactions(signed). It still works and its return type is unchanged, but it is deprecated in favour ofcreate_v2, which targets BML's documentedPOST /public/v2/transactions. The first call in a process logs a one-time deprecation warning.
# DEPRECATED v1 create — still works, returns a Faraday::Response
resp = client.transactions.create({
amount: 10000,
currency: 'MVR',
redirectUrl: '<your-redirect-uri>',
localId: 'local-1',
customerReference: 'INV-0001'
})
resp = client.transactions.get(id) # => Faraday::Response
resp = client.transactions.list({ page: 2 }) # => Faraday::ResponseThe v1 responses are Faraday::Response objects, json-encoded with symbolized names.
v2 surface (value objects)
The v2 methods target the documented endpoints, return whitelisted TransactionRecord value
objects, and raise on failure. amount MUST be a positive Integer in minor units
(10_000 = MVR 100.00); a Float or String is rejected locally, never coerced.
txn = client.transactions.create_v2(
amount: 10_000, # required — positive Integer, minor units
currency: "MVR", # required
redirectUrl: "https://merchant.example.mv/return",
localId: "INV-0001",
customerId: "cus_123" # variant 3 (or supply an inline `customer:` — variant 4)
)
txn.id
txn.state # passed through verbatim (e.g. "CONFIRMED")
txn.payment_url # hosted page to send the cardholder to (never logged/audited)
client.transactions.retrieve("txn_…") # => TransactionRecord (`get` still returns raw)
client.transactions.update("txn_…", customerReference: "…") # partial merge: customerReference/localData/pnr
client.transactions.capture("txn_…", amount: 10_000) # amount obeys the same rule as create
client.transactions.cancel("txn_…")Store a card while taking payment by adding tokenizationDetails; the token appears under the
customer (via client.tokens.list) only after the cardholder completes the payment:
client.transactions.create_v2(
amount: 10_000, currency: "MVR", customerId: "cus_123",
redirectUrl: "https://merchant.example.mv/return",
tokenizationDetails: {
tokenize: true,
paymentType: "RECURRING", # or "UNSCHEDULED"
recurringFrequency: "MONTHLY", # required when RECURRING
expiryDate: "2027-01-01" # required when RECURRING; yyyy-mm-dd, future
}
)Create and capture are never auto-retried (no documented idempotency key); retrieve, list,
update and cancel retry with backoff. Which v2 response field carries the hosted payment URL is
still being verified against UAT — until then, payment_url raises UnverifiedFieldError rather
than returning nil, so create_v2 should not be adopted in production yet.
Customers
The customers resource wraps BML's /public-customers endpoints. Unlike transactions (which
returns a raw Faraday::Response), it returns whitelisted value objects and raises on
failure. Reach it through the same configured client:
customer = client.customers.create(name: "Aisha Ali", email: "aisha@example.mv")
customer.id # => BML-assigned id, the handle for tokens and charges
customer.companyId # => platform-set
client.customers.retrieve(customer.id) # => Customer
client.customers.list.each { |c| puts c.email } # Enumerable {count, items} envelope
client.customers.update(customer.id, billingCity: "Male") # partial merge: only supplied keys sent
client.customers.archive(customer.id) # => true (soft delete; record keeps deleted: true)Notes:
-
Required on create:
nameandemail. Optional documented fields (billingEmail,billingAddress1,billingCity,billingCountry,billingPostCode,taxId, …) pass through unchanged.email/billingEmailget a lightweight shape check (must contain an@and a domain); BML remains the authority beyond that. -
Update is a partial merge (
PATCH): only the keys you pass are sent — an unset field is never serialized asnull, so it cannot erase stored data. - No card data. Every caller-supplied string is screened for a card-number pattern and rejected locally before any request; responses are whitelisted so a stray card field can never reach an object, a log line, or the audit trail.
-
Auditing.
create,updateandarchiveemit a masked, structured audit line through the client's logger (who / what / when / outcome). Pass an optionalactor:to attribute the call; reads are not audited. Inject your own logger viaClient.new(options: { logger: my_logger }). -
Errors are a typed hierarchy under
BMLConnect::Error:ValidationError(carries#field),NotFoundError,AuthenticationError,ConflictError,RateLimitError(carries#retry_after), andAvailabilityError(timeouts / 5xx after bounded retries).
Stored cards (tokens)
The tokens resource wraps BML's /public-customers/{customerId}/tokens endpoints. It is
read-and-delete only — list, retrieve, delete — because BML publishes no endpoint that
creates a token. A stored card comes into existence only as a side effect of a tokenizing
transaction (feature 003), completed by the cardholder on BML's hosted page; see
How tokenization actually works. Every path is nested under a
customerId.
# List a customer's saved cards
client.tokens.list("cus_123").each do |t|
puts "#{t.brand} #{t.paddedCardNumber} exp #{t.tokenExpiryMonth}/#{t.tokenExpiryYear}"
end
client.tokens.retrieve("cus_123", "tok_456") # => Token
client.tokens.delete("cus_123", "tok_456", actor: "ops:jane") # => true (soft delete)Notes:
-
No create, no detokenize. There is deliberately no
client.tokens.create/tokenize/detokenize— the absence is enforced by a test. If you are looking for one, tokens are created via feature003, not here. -
No card data. No operation accepts a PAN, CVV, expiry, or capture handle. A token is
represented only by BML's own fields (
token,brand,paddedCardNumber,tokenExpiryMonth,tokenExpiryYear, …); the gem invents nolast_four/schemealias and exposes no full card number. Responses are whitelisted, so a stray card field can never reach an object, log, or audit record. -
Empty vs. broken. An empty token list is an empty
TokenList, never an error, and an authentication failure is never swallowed into one. -
Auditing. Only
deleteis audited (reads are not); it emits a single masked audit line — once, even if the delete is retried. Pass an optionalactor:(screened for card data) to attribute it. -
Retries. Transient failures (
429,408, timeouts,5xx) are retried on all three operations — delete included, since soft-delete is idempotent — with bounded backoff, then raised asRateLimitError/AvailabilityError. Tune viaClient.new(options: { max_retries: 2, retry_backoff: 0.5 }); setmax_retries: 0to disable. A429'sRetry-Afteris surfaced onRateLimitError#retry_after.
Charging a stored card
Once a customer has a stored card (a token), you can take a payment from it with no cardholder present — a subscription renewal, an account top-up, an ad-hoc charge. It is a two-call flow, and the gem keeps it that way on purpose: BML has no single endpoint that creates a transaction and charges it, so this gem offers none either (a hidden combined call would obscure a money movement). The amount lives on the transaction, not on the charge.
# 1 ── create the transaction to be charged (the amount lives HERE)
renewal = client.transactions.create_v2(
amount: 10_000, currency: "MVR", # MVR 100.00, in minor units
customerId: "cus_123", localId: "SUB-2026-10"
)
# 2 ── charge the stored card against it
charged = client.customers.charge(
customer_id: "cus_123",
transaction_id: renewal.id,
token_id: "tok_789", # the stored card's Token#id
actor: "billing:cron" # optional, for the audit "who"
)
charged.state # resolved synchronously — no cardholder redirectNotes:
-
Returned vs. raised. A returned
TransactionRecordmeans BML answered — inspectcharged.statefor the business outcome (a decline is a returned record, not an exception). A raised error means BML did not answer. The gem never converts one into the other, so a declined card and a network outage are always distinguishable. -
Never auto-retried. The charge schema carries no idempotency key, so a retry could take
payment twice. Exactly one HTTP attempt is made. On a timeout, an
AvailabilityErroris raised whose message names thetransaction_id— reconcile by retrieving that transaction (client.transactions.retrieve(id)) rather than re-charging blindly. -
No
amounton the charge. To bill a different amount, create a different transaction. - Audited, failures included. Every charge emits a masked audit line — success, decline, validation failure, and availability failure alike — carrying the transaction and token ids and never any card data.
-
⚠️ Release-blocked. It is not yet confirmed against a live environment whether
token_idexpectsToken#idorToken#token.Token#idis the current inference; do not rely on this in production until it is verified (seespecs/004-token-charge/).
Receiving transaction-status webhooks
BML can notify your application when a transaction's status changes. The published contract documents how to register a hook URL and nothing at all about the notification it sends — no payload schema, no event list, and no signature, secret, or authentication. So this handler never trusts the delivery: it reads one field (which transaction) and takes every consequential fact from a fresh retrieve.
Framework-agnostic by design — the gem is handed a body and returns a value object. It adds no web framework dependency, owns no route, and never writes a response.
# config/routes.rb → post "/bml/notify" => "bml#notify"
def notify
result = BML.client.webhooks.handle(
body: request.body.read,
headers: request.env # Rails request.headers works too
)
Order.find_by!(transaction_id: result.transaction_id)
.apply_bml_status(result.status) # ← authoritative, from BML
head result.advisory_http_status # 200
rescue BMLConnect::Error => e
head e.advisory_http_status || 500 # 4xx = stop; 503 = please redeliver
endThe status never comes from the payload. A forged "payment confirmed" posted to your public
endpoint cannot ship goods: the library retrieves the transaction and reports what BML says. There is
no flag that turns this off — handle(verify: false) raises ArgumentError, because the option does
not exist.
result.status # authoritative, retrieved fresh from BML — verbatim, never coerced
result.claimed_status # what the delivery claimed. Diagnostic only. Never act on it.
result.disagreed? # they differed → a replay, a read-path lag, or a forgery
result.transaction # BMLConnect::Models::TransactionRecordExpect states this gem has never heard of: the published contract enumerates none, so nothing is
coerced or rejected. Treat result.status as current-as-of-retrieve, not final.
What to return to BML
Every result and error carries advisory_http_status. It is advice — the library never writes a
response and does not require you to honor it.
| Outcome | Advisory | Meaning to the sender |
|---|---|---|
| Accepted | 200 |
done |
| Malformed / empty / unparseable body | 400 |
permanent — do not redeliver |
| Secret absent or wrong | 401 |
permanent |
| No identifier extractable | 422 |
permanent |
| Transaction does not exist | 422 |
permanent |
| BML unreachable, timeout, 5xx, rate limited | 503 |
transient — please redeliver |
| Our API key rejected | 503 |
transient |
404 is never advised. A missing transaction gets 422 instead: 404 from an HTTP endpoint reads as
"no such endpoint", and a sender that decides your hook URL is gone may stop delivering — turning one
forged identifier into an outage for every genuine notification after it.
Configuration
BMLConnect::Client.new(
api_key: ENV.fetch("BML_API_KEY"),
options: {
webhook_secret: ENV["BML_WEBHOOK_SECRET"], # optional
webhook_recheck_delay: 3 # seconds; 0 disables the re-check
}
)webhook_secret gates spend, not trust. With one configured, a delivery whose presented value is
absent or wrong is rejected before any retrieve is spent. A match never causes the payload to be
believed and never skips the retrieve — the same notification reports an identical status either way.
You extract the value; the library only compares it (in constant time). It never guesses where the secret travels and never asks for the request URL. BML controls the callback's headers, so in practice a secret can only ride in the URL you registered:
# registered hook URL: https://you.example/bml/notify?t=SECRET
BML.client.webhooks.handle(body: body, headers: headers, presented_secret: params[:t])Enabling a secret on an existing hook? Re-register or update the URL first. Deliveries with nothing to present are rejected, and legitimate notifications drop silently until you do.
webhook_recheck_delay is spent inside your request. When the payload's claimed status disagrees
with the first retrieve, the library waits (default 3 seconds) and retrieves once more — BML's read
path may lag the event that fired the callback. If your endpoint's response deadline is at or below
3 seconds, set this to 0; a sender that times out will redeliver, turning a latency problem into a
replay problem. The cost lands on every disagreeing delivery, including every replay.
At most two retrieves per notification, neither auto-retried. Nothing a payload claims produces a third.
Limits you own, not the gem
-
Request size. The library enforces no body size or nesting limit and parses whatever you
hand it — it does not own the socket, so a limit it invented would be the wrong one. Bound it at
your web server (
client_max_body_size, a Rack middleware). - Every request costs a retrieve when no secret is configured. Your endpoint is public; protecting it is yours.
-
Deduplication and ordering. The gem owns no datastore and deduplicates nothing. Deliveries are
at-least-once and possibly out of order;
result.transaction_idis surfaced so you can key on it. Handling the same delivery twice produces equal results against an unchanged record, with no side effect beyond the retrieve. -
Registration.
POST/DELETE /public/webhooksare not implemented. Register through the merchant portal, or per-transaction viawebhook:oncreate_v2.
If a notification never arrives, reconcile directly — no webhook needed:
BML.client.transactions.retrieve(transaction_id).stateWhen the built-in field names are wrong
The identifier is looked for under transactionId, transaction_id, then id (top-level keys only).
All three are inferences from the published document, not observed facts. If you have seen your
own deliveries and know the real field, say so and the library will not second-guess you:
BML.client.webhooks.handle(
body: body, headers: headers,
extract_id: ->(payload, headers) { payload.dig(:transaction, :id) }
)The callable receives the parsed payload and the normalized headers — headers included so an
identifier arriving outside the body does not block you. Return nil for "not found" and you get the
same loud ValidationError as an exhausted list, with no remote call.
Bodies are accepted as JSON or form-encoded, selected by Content-Type (with a documented fallback
when it is absent). Header names are normalized for you, so request.env, request.headers, and a
plain hash all work.
⚠️ Not release-verified. Which inbound field carries the transaction identifier has not been observed against UAT, so every built-in candidate is
[UNVERIFIED]. A wrong guess fails loudly rather than reporting a false status, andextract_id:bypasses the list — but confirm it against a real delivery before relying on the defaults. Seespecs/005-webhook-handler/.
Roadmap
Tokenization support is moving into this gem. It was previously being built as a separate
bml_tokenization gem, which turned out to have been specified against an assumed API contract
that does not match BML's published one — see MIGRATION.md for the full account.
Work is now spec-driven against BML's own OpenAPI document, vendored at
reference/Connect-API.json. Where this library and that document
disagree, the document wins.
| Feature | Endpoints | Status |
|---|---|---|
001-customers-endpoints |
/public-customers |
Implemented (pending live UAT verification) |
002-stored-card-tokens |
/public-customers/{id}/tokens |
Implemented (pending live UAT verification) |
003-transactions-v2 |
/public/v2/transactions |
Specified |
004-token-charge |
/public-customers/charge |
Implemented, release-blocked (pending live UAT: tokenId identity) |
005-webhook-handler |
inbound callback + /public/transactions/{id}
|
Implemented, release-blocked (pending live UAT: which field carries the transaction id) |
Each feature directory carries a spec.md, plan.md, research.md, data-model.md, tasks.md
and two contracts — one for the BML HTTP surface, one for the Ruby surface this gem exposes.
How tokenization actually works
Worth stating up front, because it is not what most payment APIs do: there is no endpoint that
creates a token. A stored card comes into existence as a side effect of a transaction that
carries tokenizationDetails, completed by the cardholder on BML's hosted page. Later charges
are a two-step flow — create a transaction, then charge it against the stored token.
create customer ─► create transaction with tokenizationDetails ─► cardholder pays
│
charge stored token ◄── token now listed ◄────────┘
Backward compatibility
The transactions methods documented above are not changing. create, get and list keep
their current endpoints, signatures and Faraday::Response return type. New capabilities arrive
under new method names.
transactions.create will be marked deprecated in favour of a create_v2 targeting BML's
documented /public/v2/transactions, but it will continue to work — it is live and carries
production traffic today.
Contributing to the specs
This repository uses Spec Kit. The active feature is
tracked in .specify/feature.json:
./speckit-feature # show the active feature and the status of each
./speckit-feature 002 # switch the active featureThe project constitution is at .specify/memory/constitution.md.
Two rules matter most for anyone adding a remote call:
-
A stubbed test is not evidence that an endpoint exists. Every path must be traceable to
reference/Connect-API.json, and stub URLs must derive from the client's base URL rather than being hardcoded. -
Anything not in the published document is marked
[UNVERIFIED]and must be confirmed against UAT before it is relied on.
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/oxiqa/bml-connect-ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the BMLConnect project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.