Ruby
The wajub gem, its resources, and how it drops into a Rails application.
The wajub gem is the server side of a Wajub integration. It holds your secret key, creates
payments, reads their real status and verifies webhook signatures. It has no gem dependency at
all, only net/http from the standard library.
wajub
Stable · GARubyGems
- Version
- 1.1.1
- Runtime
- Ruby 3.1+, standard library only
Covers
- Payments
- Billing
- Transfers
- Sync
- Shield
- Tax
Install
gem 'wajub', '~> 1.1'Or without a Gemfile, since there is no transitive dependency to resolve.
gem install wajubCreate the client
The constructor takes keyword arguments and falls back to the environment, so the common case is no arguments at all.
require 'wajub'
# Reads WAJUB_API_KEY and WAJUB_WEBHOOK_SECRET when you pass nothing.
WAJUB = Wajub::Client.new
# Or pass them yourself.
WAJUB = Wajub::Client.new(
api_key: ENV.fetch('WAJUB_API_KEY'),
webhook_secret: ENV.fetch('WAJUB_WEBHOOK_SECRET')
)| Keyword | What it does |
|---|---|
api_key | Your sk. or sk_test. key. Falls back to WAJUB_API_KEY |
webhook_secret | The whsec_ secret. Falls back to WAJUB_WEBHOOK_SECRET |
idempotency_key_prefix | Prefix of the generated key. Defaults to 'wajub' |
transport | Your own object answering call, for tests or a custom stack |
An empty key raises ArgumentError: Wajub: api_key is required at construction, so a bad
configuration fails at boot.
One client, shared, is the right shape
The client keeps a single connection guarded by a mutex, so it is safe to share between Puma threads. Build it once in an initializer and reference the constant. A client per request opens a new TLS connection every time.
The first call
payment = WAJUB.payments.create(
'amount' => 25_000,
'currency' => 'XAF',
'email' => 'amina@example.com',
'description' => 'Order 4172',
'reference' => "order-#{order.id}",
'callback' => checkout_complete_url
)
redirect_to payment.authorization_url, allow_other_host: trueParameters go in as a hash with the API's exact field names. Symbol keys work too, they are
normalised on the way out. What comes back is an ApiObject, so every field reads as a method.
payment.id # trx_test_8kQ2mW9vB4nL6hR1cY3d
payment.status # pending
payment.authorization_url # https://pay.wajub.com/tok_xxxxx
payment['authorization_url'] # the same thing
payment.to_h # the whole hash, for loggingAn unknown field raises, it does not return nil
ApiObject answers only the keys the API actually sent. payment.settlement_batch_id raises
NoMethodError when that field is absent, which is the Ruby behaviour you want in a test and a
surprise in production. Use payment['settlement_batch_id'] for anything optional, since the
bracket form returns nil.
Amounts are in the major unit
25_000 with XAF is twenty-five thousand francs. A decimal currency takes a decimal:
'amount' => 12.50 with GHS.
Every resource on the client
| Property | Methods |
|---|---|
| client.global | ping, channels, countries, currencies |
| client.payments | create, initialize_payment, retrieve, list, cancel, process, process_split, list_refunds |
| client.customers | create, retrieve, update, delete, list, block, unblock, activate, deactivate, list_tax_ids, create_tax_id, delete_tax_id |
| client.refunds | create, retrieve, list |
| client.transfers | create, retrieve, list |
| client.beneficiaries | create, retrieve, update, delete, list |
| client.links | create, retrieve, update, delete, list |
| client.invoices | create, retrieve, update, delete, list, send, mark_paid, cancel |
| client.accounts | create, retrieve, update, delete, list, regenerate_token |
| client.webhook_endpoints | create, retrieve, update, delete, list, rotate_secret |
| client.balance | retrieve |
| client.events | list, retrieve, resend |
| client.disputes | list, retrieve, submit_evidence, accept, close, send_message |
| client.identity | resolve, validate |
| client.tax | get_settings, update_settings, rates, calculate, reports, list_codes, retrieve_code, list_registrations, create_registration, retrieve_registration, update_registration, delete_registration, jurisdictions, thresholds, threshold_alerts |
| client.shield | get_settings, update_settings, stats, list_blocklist, add_to_blocklist, remove_from_blocklist |
| client.listen | config, auth |
| client.webhooks | construct_event |
initializeis the Ruby constructor, so the alias ofcreateis namedinitialize_payment.webhookssignature verification runs locally, no HTTP call. All other resources call the merchant REST API.links,invoices,taxandshieldare live only. A sandbox key gets403 This feature is only available in live mode.on every one of their methods.refundsandtransfersare create, retrieve and list only. The shared CRUD base also exposesupdateanddeleteon them, but the API serves no such route.
Paging through a list
page = WAJUB.payments.list('status' => 'success', 'per_page' => 50)
page.data.each do |payment|
puts "#{payment['id']} #{payment['amount']}"
end
page = page.next_page if page.has_more
# Or let it walk every page for you.
WAJUB.payments.list.auto_paging_each do |payment|
Reconcile.call(payment)
endpage.data holds plain hashes, not ApiObject, so rows use bracket access. The method is
next_page, not get_next_page, and auto_paging_each takes the block.
Idempotency and retries
Every POST and PUT carries a generated Idempotency-Key, which is what makes the automatic
retry safe.
WAJUB.payments.create(
params,
Wajub::RequestOptions.new(idempotency_key: "order-#{order.id}")
)| What | Value |
|---|---|
| Retried statuses | 429, 500, 502, 503, 504, and any network failure |
| Attempts | 3 in total, one original plus two retries |
| Backoff | Exponential with jitter, honouring Retry-After on a 429 |
| Timeout | 30 seconds to open and 30 to read |
Create the payment in a job, not in the controller
A slow API holds a Puma thread for up to 30 seconds, and three of those starve a small pool. Put the call in an ActiveJob and give the job the same idempotency key, so a job retry lands on the same payment instead of creating a second one.
Acting for a connected account
WAJUB.payments.create(
{ 'amount' => 25_000, 'currency' => 'XAF', 'email' => buyer.email },
Wajub::RequestOptions.new(sync: seller.wajub_account_id)
)sync becomes the X-Sync header. Setup is on Sync.
Webhooks
construct_event verifies the signature and returns the parsed event. It needs the body exactly
as it arrived.
class WajubWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def create
event = WAJUB.webhooks.construct_event(
request.raw_post,
request.headers['X-Wajub-Signature'],
request.headers['X-Wajub-Timestamp']
)
HandleWajubEventJob.perform_later(event) if event['event'] == 'payment.succeeded'
head :ok
rescue Wajub::WebhookSignatureVerificationError
head :bad_request
end
endraw_post, never params
The signature covers {timestamp}.{raw body}. Rails has already parsed the body into params
by the time your action runs, and re-encoding it changes key order and spacing, so the hash
stops matching. request.raw_post returns the bytes as delivered.
A Rails webhook route needs two more things. Skip the CSRF check, since Wajub carries no session
token, and answer fast. Acknowledge with head :ok and let a job do the work, because the
delivery is retried when your endpoint takes too long.
The event name is in event, not in type, and the result is a plain hash. Tolerance is 300
seconds by default and construct_event takes a fourth positional argument. Details on
Signature verification.
Errors
begin
WAJUB.payments.create(params)
rescue Wajub::InvalidRequestError => e
# e.errors is {"amount" => ["The amount must be at least 25."]}
render json: { fields: e.errors }, status: :unprocessable_entity
rescue Wajub::RateLimitError => e
response.set_header('Retry-After', (e.retry_after || 5).to_s)
head :service_unavailable
rescue Wajub::WajubError => e
Rails.logger.error("wajub failed code=#{e.code} status=#{e.http_status}")
raise
end| Class | Raised on |
|---|---|
Wajub::AuthenticationError | 401 |
Wajub::PermissionError | 403 |
Wajub::NotFoundError | 404 |
Wajub::InvalidRequestError | 400 and 422 |
Wajub::RateLimitError | 429, with retry_after in seconds |
Wajub::WajubError | Every other status, and the parent of all of the above |
Wajub::ApiConnectionError | No response at all, network or timeout |
Wajub::WebhookSignatureVerificationError | A webhook that did not verify |
Each one exposes code, http_status, errors and raw. They all descend from StandardError,
so a bare rescue catches them.
Two Ruby specific names
# Everywhere else this alias of create is named initialize.
# In Ruby that name belongs to the constructor, so it is:
WAJUB.payments.initialize_payment(params)The other one is the accessor for webhook endpoints. The client exposes
WAJUB.webhook_endpoints, in snake case like the rest of the gem, while the API path stays
/webhook-endpoints.
Testing without touching the API
The transport keyword takes any object answering call, which makes the whole gem offline in a
test.
class FakeTransport
def initialize(status:, body:)
@status = status
@body = body
end
def call(method:, path:, headers:, body:, query:)
{ status: @status, body: @body, headers: {}, request: { method: method, path: path } }
end
end
client = Wajub::Client.new(
api_key: 'sk_test.fake',
transport: FakeTransport.new(
status: 201,
body: {
'authorization_url' => 'https://pay.wajub.com/tok_test',
'authorization_token' => 'tok_test',
'transaction' => { 'id' => 'trx_test', 'status' => 'pending' }
}
)
)
expect(client.payments.create('amount' => 25_000).authorization_token).to eq('tok_test')Charging without the hosted page
payment = WAJUB.payments.create(
'amount' => 25_000, 'currency' => 'XAF', 'phone' => '+237670000000'
)
WAJUB.payments.process(
payment.id,
'channel' => 'cm.mtn', 'phone' => '+237670000000'
)A channel is country.operator, so cm.mtn is MTN Mobile Money in Cameroon. The full list is on
Payment methods. The payer still approves on their handset, so the
outcome arrives on the webhook.
Paying out
transfer = WAJUB.transfers.create(
{
'amount' => 100_000,
'currency' => 'XAF',
'beneficiary' => {
'name' => 'Amina Diallo',
'channel' => 'cm.mtn',
'phone' => '+237670000000'
},
'reference' => 'payout-892'
},
Wajub::RequestOptions.new(idempotency_key: 'payout-892')
)beneficiary also takes the ben_… id of a saved beneficiary, which is the better shape once you
pay the same person twice.
Local development
wajub listen --forward-to localhost:3000/wajub_webhooks
wajub trigger payment.succeededMore on the CLI.
Related pages
- SDK QuickstartThe same first call, in five languages side by side.
- WebhooksEvery event, and the delivery guarantees behind them.
- Naming conventionsWhy this gem stays snake_case while Go and Java do not.
- IdempotencyWhat a key protects and for how long.
- Error handlingWhich failures to retry and which to surface.
- API referenceThe endpoints behind every method above.