ExPaymob

Hex.pmHex DocsLicense

Elixir client for the Paymob payment gateway. Supports Egypt, UAE, KSA, and Oman regions.

Features

Installation

Add ex_paymob to your dependencies:

def deps do
  [
    {:ex_paymob, "~> 0.1.0"}
  ]
end

For Phoenix projects, run the installer after adding the dependency:

mix deps.get
mix ex_paymob.install

Configuration

# config/config.exs
config :ex_paymob,
  region: :egypt  # :egypt | :uae | :ksa | :oman

# config/runtime.exs
config :ex_paymob,
  secret_key: System.get_env("PAYMOB_SECRET_KEY"),
  public_key: System.get_env("PAYMOB_PUBLIC_KEY"),
  hmac_secret: System.get_env("PAYMOB_HMAC_SECRET")

Every option can be overridden per-request:

ExPaymob.Intention.create(params, secret_key: "sk_other", region: :uae)

Configuration Resolution

Values are resolved in order (first match wins):

  1. Per-request keyword opts
  2. Application environment (config :ex_paymob, key: value)
  3. System environment (PAYMOB_SECRET_KEY, PAYMOB_PUBLIC_KEY, PAYMOB_HMAC_SECRET)

Regions

Region Base URL
:egypt (default) https://accept.paymob.com
:uaehttps://uae.paymob.com
:ksahttps://ksa.paymob.com
:omanhttps://oman.paymob.com

Usage

Payment Intentions

# Create a payment intention
{:ok, intention} = ExPaymob.Intention.create(%{
  amount: 10000,
  currency: "EGP",
  payment_methods: [integration_id],
  billing_data: %{
    first_name: "John",
    last_name: "Doe",
    email: "john@example.com",
    phone_number: "+201234567890"
  },
  items: []
})

# Build checkout redirect URL
url = ExPaymob.Intention.checkout_url(intention["client_secret"], "pk_public_key")

# Update an intention
{:ok, updated} = ExPaymob.Intention.update(intention["client_secret"], %{amount: 20000})

Transactions

# Retrieve by ID
{:ok, txn} = ExPaymob.Transaction.retrieve("12345")

# Inquire by merchant order ID
{:ok, txn} = ExPaymob.Transaction.inquire(%{merchant_order_id: "order_123"})

Refund, Void, and Capture

# Refund (partial or full)
{:ok, _} = ExPaymob.Refund.create("transaction_id", 5000)

# Void a transaction
{:ok, _} = ExPaymob.Void.create("transaction_id")

# Capture an authorized transaction
{:ok, _} = ExPaymob.Capture.create("transaction_id", 10000)

Subscriptions

# Create a plan
{:ok, plan} = ExPaymob.SubscriptionPlan.create(%{
  name: "Premium",
  amount: 5000,
  currency: "EGP",
  interval: "month"
})

# Manage plans
{:ok, _} = ExPaymob.SubscriptionPlan.suspend(plan["id"])
{:ok, _} = ExPaymob.SubscriptionPlan.resume(plan["id"])
{:ok, plans} = ExPaymob.SubscriptionPlan.list()

# Create a subscription
{:ok, sub} = ExPaymob.Subscription.create(%{plan_id: plan["id"]})
{:ok, _} = ExPaymob.Subscription.suspend(sub["id"])
{:ok, _} = ExPaymob.Subscription.resume(sub["id"])

Error Handling

All API calls return {:ok, map()} or {:error, %ExPaymob.Error{}}:

case ExPaymob.Intention.create(params) do
  {:ok, intention} ->
    intention["client_secret"]

  {:error, %ExPaymob.Error{source: :paymob, status: 422, message: message}} ->
    Logger.error("Validation error: #{message}")

  {:error, %ExPaymob.Error{source: :network, message: message}} ->
    Logger.error("Network error: #{message}")
end

Error sources: :paymob (API errors), :network (transport failures), :internal (decode errors).

Webhook Verification

Paymob signs transaction callbacks with HMAC-SHA512. ExPaymob verifies these signatures using timing-safe comparison.

Standalone Verification

# Verify and parse in one step
{:ok, event} = ExPaymob.Webhook.verify_and_parse(payload, hmac_from_query)

# Or verify separately
:ok = ExPaymob.Webhook.verify_hmac(payload, hmac, hmac_secret: "secret")

# Subscription webhooks use a different format
:ok = ExPaymob.Webhook.verify_subscription_hmac(payload, hmac)

Phoenix Webhook Endpoint

Step 1: Configure raw body caching in your endpoint (before Plug.Parsers):

# lib/my_app_web/endpoint.ex
plug Plug.Parsers,
  parsers: [:json],
  pass: ["application/json"],
  json_decoder: Jason,
  body_reader: {ExPaymob.Plug.RawBodyReader, :read_body, []}

Step 2: Add the webhook route in your router:

# lib/my_app_web/router.ex
scope "/webhooks" do
  pipe_through :api

  forward "/paymob", ExPaymob.Plug.WebhookPlug,
    handler: MyAppWeb.PaymobWebhookHandler
end

Step 3: Implement your handler:

defmodule MyAppWeb.PaymobWebhookHandler do
  def handle_event(conn, event) do
    # event is the "obj" map from the webhook payload
    if event["success"] do
      # Payment succeeded - update your order, send confirmation, etc.
    else
      # Payment failed
    end

    # Return conn. The plug sends 200 automatically if you don't send a response.
    conn
  end
end

The plug handles HMAC verification automatically — returns 401 for invalid signatures, 400 for malformed requests.

Custom HTTP Client

Replace the default Req adapter by implementing ExPaymob.HttpClient:

defmodule MyApp.CustomHttpClient do
  @behaviour ExPaymob.HttpClient

  @impl true
  def request(method, url, headers, body, opts) do
    # Your HTTP implementation
    # Must return {:ok, status, resp_headers, resp_body} | {:error, reason}
  end
end
# Global
config :ex_paymob, http_client: MyApp.CustomHttpClient

# Per-request
ExPaymob.Intention.create(params, http_client: MyApp.CustomHttpClient)

Testing

For testing, use Mox with the ExPaymob.HttpClient behaviour:

# test/test_helper.exs
Mox.defmock(ExPaymob.HttpClientMock, for: ExPaymob.HttpClient)
Application.put_env(:ex_paymob, :http_client, ExPaymob.HttpClientMock)

# test/my_test.exs
import Mox

test "handles successful payment" do
  expect(ExPaymob.HttpClientMock, :request, fn :post, _url, _headers, _body, _opts ->
    {:ok, 200, [], Jason.encode!(%{"id" => "123", "client_secret" => "cs_test"})}
  end)

  assert {:ok, %{"id" => "123"}} = ExPaymob.Intention.create(%{amount: 1000})
end

License

MIT - see LICENSE for details.