Optional verification helpers for inbound M-PESA webhook requests.
Safaricom Daraja callbacks are unsigned JSON POSTs. Authenticity must be enforced by the host application—typically via HTTPS, a shared secret embedded in the callback URL, IP allowlisting, idempotency on transaction IDs, and reconciliation against outbound API calls or Safaricom query/status APIs.
This module does not prove a payment occurred; it only helps reject requests that clearly did not come through your expected transport controls.
IP allowlist provenance
The built-in defaults are community-documented Safaricom Kenya callback egress addresses widely cited in M-Pesa integrator guides and open-source packages. They are not fetched from the live Daraja API and may change without notice.
Defaults combine:
- CIDR subnets
196.201.212.0/24,196.201.213.0/24, and196.201.214.0/24(Safaricom196.201.x.xaddress space) - An explicit host list matching commonly published production callback IPs
Verify the current list with Safaricom support or your own production logs, then override via application config:
config :daraja,
callback_cidrs: ["196.201.214.0/24", "196.201.213.0/24", "196.201.212.0/24"],
callback_hosts: ["196.201.214.200", "196.201.212.127"]Per-request overrides are also supported through verify/1 and safaricom_ip?/2.
IP allowlisting is intended for production. Sandbox callbacks (for example
via ngrok) typically will not match these ranges—disable check_ip in dev.
Example (Phoenix)
with :ok <-
Daraja.Callback.Security.verify(
ip: conn.remote_ip,
check_ip: true,
shared_secret: Application.fetch_env!(:my_app, :mpesa_callback_secret),
provided_secret: conn.params["token"]
),
{:ok, callback} <- Daraja.Express.Callback.parse(payload),
:ok <- Daraja.Callback.Guard.ensure_fresh(callback.checkout_request_id) do
# ...fulfil order...
json(conn, Daraja.Express.Callback.accept())
else
{:error, :untrusted_ip} -> send_resp(conn, 403, "Forbidden")
{:error, :invalid_secret} -> send_resp(conn, 403, "Forbidden")
{:error, :invalid_callback, _} -> send_resp(conn, 400, "Bad Request")
{:error, :duplicate} -> json(conn, Daraja.Express.Callback.accept())
endRegister callback URLs with a secret query parameter, for example
https://example.com/mpesa/callback?token=your-secret, and pass the same
value to provided_secret.
Summary
Functions
Returns the active CIDR and host allowlists as a map.
Returns the configured callback CIDR allowlist.
Returns the configured explicit callback host allowlist.
Built-in CIDR defaults shipped with the library.
Built-in explicit host defaults shipped with the library.
Returns true when ip matches the active allowlist.
Constant-time comparison of callback URL secrets.
Runs enabled verification checks. Skips any check whose inputs are omitted.
Types
Functions
@spec allowlist() :: allowlist()
Returns the active CIDR and host allowlists as a map.
@spec callback_cidrs() :: [String.t()]
Returns the configured callback CIDR allowlist.
Reads :callback_cidrs from config :daraja, ... when set, otherwise
default_cidrs/0.
@spec callback_hosts() :: [String.t()]
Returns the configured explicit callback host allowlist.
Reads :callback_hosts from config :daraja, ... when set, otherwise
known_callback_hosts/0.
@spec default_cidrs() :: [String.t()]
Built-in CIDR defaults shipped with the library.
@spec known_callback_hosts() :: [String.t()]
Built-in explicit host defaults shipped with the library.
These mirror commonly published Safaricom production callback IPs cited in
integrator documentation. Prefer callback_hosts/0 at runtime.
Returns true when ip matches the active allowlist.
ip may be an IPv4 tuple, or a string parseable by :inet.parse_address/1.
Only IPv4 matching is supported.
When the second argument is a plain list of CIDR strings (no cidr/host keys),
only those CIDRs are checked—useful in tests. Otherwise pass [] or a keyword
list with optional :cidrs and :hosts overrides.
@spec verify(keyword()) :: :ok | {:error, verify_error()}
Runs enabled verification checks. Skips any check whose inputs are omitted.
Options:
:ip— client address as an IPv4/IPv6 tuple or string (e.g.conn.remote_ip):check_ip— whentrue, requires:ipto match:cidrsand/or:hosts:cidrs— CIDR strings for this request; defaults tocallback_cidrs/0:hosts— explicit IPv4 strings for this request; defaults tocallback_hosts/0:shared_secret— expected secret (e.g. from app config):provided_secret— secret from the callback URL query/path
Returns :ok or {:error, reason}.