Skip to content

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 · GA

RubyGems

Version
1.1.1
Runtime
Ruby 3.1+, standard library only

Covers

  • Payments
  • Billing
  • Transfers
  • Sync
  • Shield
  • Tax

Install

Gemfile
gem 'wajub', '~> 1.1'

Or without a Gemfile, since there is no transitive dependency to resolve.

Straight from RubyGems
gem install wajub

Create the client

The constructor takes keyword arguments and falls back to the environment, so the common case is no arguments at all.

config/initializers/wajub.rb
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')
)
KeywordWhat it does
api_keyYour sk. or sk_test. key. Falls back to WAJUB_API_KEY
webhook_secretThe whsec_ secret. Falls back to WAJUB_WEBHOOK_SECRET
idempotency_key_prefixPrefix of the generated key. Defaults to 'wajub'
transportYour 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

Create a payment
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: true

Parameters 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.

Reading the result
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 logging

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

PropertyMethods
client.globalping, channels, countries, currencies
client.paymentscreate, initialize_payment, retrieve, list, cancel, process, process_split, list_refunds
client.customerscreate, retrieve, update, delete, list, block, unblock, activate, deactivate, list_tax_ids, create_tax_id, delete_tax_id
client.refundscreate, retrieve, list
client.transferscreate, retrieve, list
client.beneficiariescreate, retrieve, update, delete, list
client.linkscreate, retrieve, update, delete, list
client.invoicescreate, retrieve, update, delete, list, send, mark_paid, cancel
client.accountscreate, retrieve, update, delete, list, regenerate_token
client.webhook_endpointscreate, retrieve, update, delete, list, rotate_secret
client.balanceretrieve
client.eventslist, retrieve, resend
client.disputeslist, retrieve, submit_evidence, accept, close, send_message
client.identityresolve, validate
client.taxget_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.shieldget_settings, update_settings, stats, list_blocklist, add_to_blocklist, remove_from_blocklist
client.listenconfig, auth
client.webhooksconstruct_event
  • initialize is the Ruby constructor, so the alias of create is named initialize_payment.
  • webhooks signature verification runs locally, no HTTP call. All other resources call the merchant REST API.
  • links, invoices, tax and shield are live only. A sandbox key gets 403 This feature is only available in live mode. on every one of their methods.
  • refunds and transfers are create, retrieve and list only. The shared CRUD base also exposes update and delete on them, but the API serves no such route.

Paging through a list

One page, or all of them
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)
end

page.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.

An order number is the best key
WAJUB.payments.create(
  params,
  Wajub::RequestOptions.new(idempotency_key: "order-#{order.id}")
)
WhatValue
Retried statuses429, 500, 502, 503, 504, and any network failure
Attempts3 in total, one original plus two retries
BackoffExponential with jitter, honouring Retry-After on a 429
Timeout30 seconds to open and 30 to read

Acting for a connected account

One call, one seller
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.

A Rails controller
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
end

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

Rescue the specific one first
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
ClassRaised on
Wajub::AuthenticationError401
Wajub::PermissionError403
Wajub::NotFoundError404
Wajub::InvalidRequestError400 and 422
Wajub::RateLimitError429, with retry_after in seconds
Wajub::WajubErrorEvery other status, and the parent of all of the above
Wajub::ApiConnectionErrorNo response at all, network or timeout
Wajub::WebhookSignatureVerificationErrorA 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

The constructor took the good name
# 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.

A stubbed transport
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

Mobile Money push
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

A transfer to a phone number
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

Two terminals
wajub listen --forward-to localhost:3000/wajub_webhooks
wajub trigger payment.succeeded

More on the CLI.

What did you think of this content?