# ExPaymob
[](https://hex.pm/packages/ex_paymob)
[](https://hexdocs.pm/ex_paymob)
[](https://github.com/petershoukry/ex_paymob/blob/main/LICENSE)
Elixir client for the [Paymob](https://paymob.com) payment gateway. Supports Egypt, UAE, KSA, and Oman regions.
## Features
- **Payment Intentions** - Create and manage payments via the V1 API
- **Transaction Management** - Retrieve, inquire, refund, void, and capture
- **Subscriptions** - Plan and subscription CRUD
- **Webhook Verification** - HMAC-SHA512 with timing-safe comparison
- **Phoenix Integration** - Plug for webhook endpoints with automatic verification
- **Multi-Region** - Egypt, UAE, KSA, Oman with per-request overrides
- **Swappable HTTP Client** - Default Req adapter, bring your own via behaviour
- **Igniter Installer** - One command Phoenix setup
## Installation
Add `ex_paymob` to your dependencies:
```elixir
def deps do
[
{:ex_paymob, "~> 0.1.0"}
]
end
```
For Phoenix projects, run the installer after adding the dependency:
```bash
mix deps.get
mix ex_paymob.install
```
## Configuration
```elixir
# 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:
```elixir
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` |
| `:uae` | `https://uae.paymob.com` |
| `:ksa` | `https://ksa.paymob.com` |
| `:oman` | `https://oman.paymob.com` |
## Usage
### Payment Intentions
```elixir
# 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
```elixir
# 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
```elixir
# 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
```elixir
# 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{}}`:
```elixir
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
```elixir
# 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`):
```elixir
# 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:
```elixir
# 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:
```elixir
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`:
```elixir
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
```
```elixir
# Global
config :ex_paymob, http_client: MyApp.CustomHttpClient
# Per-request
ExPaymob.Intention.create(params, http_client: MyApp.CustomHttpClient)
```
## Testing
For testing, use [Mox](https://hex.pm/packages/mox) with the `ExPaymob.HttpClient` behaviour:
```elixir
# 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](LICENSE) for details.