Metadata-Version: 2.5
Name: momo-openapi
Version: 0.1.1
Summary: Python client for the MTN MoMo Open API, ready for the momoapi.momo.africa platform migration.
Author-email: Dali Hillary <hillary@zem.bo>
License-Expression: MIT
License-File: LICENSE
Keywords: collections,disbursements,mobile money,momo,mtn,payments,remittance
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: requests>=2.25
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: python-dotenv>=1.0; extra == 'dev'
Requires-Dist: responses>=0.23; extra == 'dev'
Requires-Dist: tenacity>=8; extra == 'dev'
Provides-Extra: dotenv
Requires-Dist: python-dotenv>=1.0; extra == 'dotenv'
Description-Content-Type: text/markdown

# momo-openapi

Python client for the MTN MoMo Open API (Collections, Disbursements and Remittances), built for the migration from `proxy.momoapi.mtn.com` to the new platform at `momoapi.momo.africa`.

```bash
pip install momo-openapi
```

Requires Python 3.9+ and `requests`.

## What the migration changes

If your integration reaches MoMo over the internet through `https://proxy.momoapi.mtn.com`, you have to migrate. Integrations over a VPN are not affected.

| | Legacy platform | New platform |
|---|---|---|
| Base URL | `https://proxy.momoapi.mtn.com` (IP `172.205.99.23`) | `https://momoapi.momo.africa` (use the FQDN, not an IP) |
| Credentials | API User + API Key | User ID + Credential Token, from the Partner Portal |
| `X-Target-Environment` on `*/token/` | Optional | **Mandatory** |
| Callback source IPs | `212.88.125.100`, `212.88.100.195` | Shared by MTN on request |

Subscription keys, the `X-Target-Environment` value, request and response bodies, and callbacks passed in `X-Callback-Url` all stay the same.

MTN's documents don't mention these behaviours of the new platform's gateway. They were found by testing it, and this package handles all of them:

- **No `python-requests` User-Agent.** Any `User-Agent` containing `python-requests` (the `requests` library's default) gets an HTML `403 Forbidden` page from "Microsoft-Azure-Application-Gateway". This package sends `User-Agent: momo-openapi/<version>`.
- **Credentials go into the Basic header raw.** New User IDs are base64 strings ending in `=`, and tokens contain punctuation. If they are URL-encoded before base64, as the `basicauth` package's `encode()` does, the gateway answers `500 Internal server error`. Send `base64("<user id>:<token>")` exactly as the portal shows them.
- **A missing or wrong `X-Target-Environment` on `*/token/`** gets `400 {"error": "Invalid or missing X-Target-Environment header"}`.

This package sends `X-Target-Environment` on every token request and defaults to the new base URL. Most migrations therefore only need new credential values and, before cutover, a `BASE_URL` override.

## Quick start

```python
from momo_openapi import Collection, Disbursement

collection = Collection.from_env()

reference = collection.request_to_pay("1500", "256772123456", external_id="order-42",
                                      payer_message="Order 42", payee_note="Thank you")
status = collection.get_transaction_status(reference)
print(status["status"])  # PENDING, SUCCESSFUL or FAILED

disbursement = Disbursement.from_env()
payout = disbursement.transfer("500", "256772123456", external_id="payout-7")
```

`request_to_pay()` and `transfer()` return the `X-Reference-Id` once MoMo accepts the request (HTTP 202). The outcome arrives later, on your callback URL or through `get_transaction_status()`. Pass `reference_id=` to use your own UUID, for example to retry safely.

You can also configure a client in code:

```python
from momo_openapi import Collection, MomoConfig

collection = Collection(MomoConfig(
    subscription_key="...",       # Ocp-Apim-Subscription-Key, unchanged by the migration
    user_id="...",                # Partner Portal User ID
    credential_token="...",       # Partner Portal Credential Token
    target_environment="mtnuganda",
    currency="UGX",
    callback_url="https://example.com/momo/callback",
))
```

Every client also has `get_balance()` and `get_access_token()`. Tokens are cached and renewed a minute before they expire.

## Configuration from the environment

The variable names match the older `mtnmomoapi` integration, so existing deployments only need new values.

| Variable | Meaning |
|---|---|
| `COLLECTION_PRIMARY_KEY` | Subscription key for the product. `DISBURSEMENT_` and `REMITTANCE_` variants work the same way. |
| `COLLECTION_USER_ID` | User ID. Falls back to `MOMO_USER_ID`. |
| `COLLECTION_CREDENTIAL_TOKEN` | Credential Token. Falls back to the old `COLLECTION_API_SECRET`, then `MOMO_CREDENTIAL_TOKEN`. |
| `MTN_ENVIRONMENT` | `X-Target-Environment`, e.g. `mtnuganda`, `mtnzambia` or `sandbox`. Required. |
| `PLATFORM` | Optional. `legacy` or `new`: the platform to use when the code passes no `platform=`, see below. |
| `BASE_URL` | Optional. Without a platform, defaults to `https://momoapi.momo.africa`, or to the sandbox when `MTN_ENVIRONMENT=sandbox`. For the legacy platform, defaults to `https://proxy.momoapi.mtn.com`. |
| `CALLBACK_URL` | Optional. Sent as `X-Callback-Url`. |
| `CURRENCY` | Default currency, e.g. `UGX`. The sandbox only accepts `EUR`. |
| `MOMO_PROXY_URL` | Optional outbound proxy for static egress IPs. Falls back to `QUOTAGUARDSTATIC_URL`. |
| `MOMO_TIMEOUT` | Request timeout in seconds. Default `30`. |
| `NEW_BASE_URL` | Optional. The new platform's base URL, read only for the new platform. Defaults to `https://momoapi.momo.africa`. |
| `COLLECTION_NEW_USER_ID`, `COLLECTION_NEW_CREDENTIAL_TOKEN` | Optional. The new platform's credentials, kept next to the old ones while both platforms run. Read only for the new platform, see below. |

### Both platforms side by side

During the migration you'll have two credential pairs: the old API user and key, and the new System Credentials. Configure both, then choose a platform with `PLATFORM`, or with `platform=` in code, which wins over `PLATFORM`. Each platform reads its own base URL and credential pair. The unprefixed names belong to the legacy platform and the `NEW_` names to the new one:

| `PLATFORM` / `platform=` | Base URL | Credentials read |
|---|---|---|
| `legacy` | `BASE_URL`, default `https://proxy.momoapi.mtn.com` | `COLLECTION_USER_ID` + `COLLECTION_API_SECRET` |
| `new` | `NEW_BASE_URL`, default `https://momoapi.momo.africa` | `COLLECTION_NEW_USER_ID` + `COLLECTION_NEW_CREDENTIAL_TOKEN`, else `COLLECTION_USER_ID` + `COLLECTION_CREDENTIAL_TOKEN`, else `MOMO_USER_ID` + `MOMO_CREDENTIAL_TOKEN` |

Switching platforms is then a change to one variable, with no code change. Set it back to roll back:

```bash
PLATFORM=legacy   # new at cutover
BASE_URL=https://proxy.momoapi.mtn.com
NEW_BASE_URL=https://momoapi.momo.africa
```

```python
collection = Collection.from_env()                  # follows PLATFORM
legacy = Collection.from_env(platform="legacy")     # always legacy
new = Collection.from_env(platform="new")           # always new
```

`PLATFORM` is case-insensitive, and empty means unset. Any other value raises `ConfigurationError`. Without either, `BASE_URL` picks the URL and the default credential names are read, as in the table above. A pair is always taken whole. The new platform never falls back to the old `*_API_SECRET`, because one platform's credentials never work on the other. [examples/](examples/README.md) has a script per platform for each operation.

### Using a `.env` file

Copy [.env.example](.env.example) to `.env` and fill it in. Variables already set in the real environment take precedence over the file.

- The `momo-openapi` CLI reads `./.env` automatically. Use `--env-file path` for a different file. This needs `pip install "momo-openapi[dotenv]"`.
- In your own app, load the file yourself. Either call `load_dotenv()` before `from_env()`, or hand the values over directly:

  ```python
  from dotenv import dotenv_values
  from momo_openapi import Collection

  collection = Collection.from_env(dotenv_values(".env"))
  ```

Credentials are issued per wallet, not per API. If Collections and Disbursements share a wallet, set `MOMO_USER_ID` and `MOMO_CREDENTIAL_TOKEN` once. Products on different wallets each need their own pair.

## Migration checklist

**You can do these now:**

1. Confirm you call `proxy.momoapi.mtn.com` (VPN integrations are not affected). Find every place your app references that URL.
2. Upgrade to this package. It already sends `X-Target-Environment` on token requests.
3. Check that the new base URL is reachable from your servers, through your firewall and proxy:
   ```bash
   momo-openapi check --platform new
   ```
4. If you whitelist MTN source IPs for callbacks, ask for the new list by replying to the migration email, and whitelist it before cutover.
5. If you lack Partner Portal access, contact your account manager or support now.

**After MTN confirms the new platform is ready:**

6. In the Partner Portal, open **System and device credentials**, choose **+** under **System Credentials**, name the credential and choose **Create credential**. Copy the User ID and Credential Token immediately, because the token is not shown again.
7. Put them in `*_NEW_USER_ID` / `*_NEW_CREDENTIAL_TOKEN`, next to your old API user and key. At cutover, set `PLATFORM=new`.
8. Verify the credentials work:
   ```bash
   momo-openapi token --platform new              # every configured product
   momo-openapi token collection --platform new   # just one
   ```
   It prints which platform and target environment issued the token. The token itself is never printed. Without `--platform`, it uses `PLATFORM`, else `BASE_URL` and the default credential names.

Until the new platform is confirmed, stay on the legacy platform with your existing API user and key in `*_USER_ID` / `*_API_SECRET`:

```bash
PLATFORM=legacy
```

For questions, write to support.momodeveloper@mtn.com and cc your account manager.

## Running both platforms (pilot traffic)

Both platforms run in parallel for one month after cutover. You can send part of your traffic to the new one first. Both kinds of transaction show up in the Partner Portal. **A transaction's status can only be read on the platform that processed it.** Keep a client per platform, and store `client.base_url` with each reference. These clients pass `platform=`, so `PLATFORM` doesn't affect them:

```python
from momo_openapi import Collection

new = Collection.from_env(platform="new")
legacy = Collection.from_env(platform="legacy")
clients = {c.base_url: c for c in (new, legacy)}

client = new if in_pilot(order) else legacy
reference = client.request_to_pay(amount, msisdn, external_id=order.id)
save(order, reference=reference, platform=client.base_url)

# later
status = clients[order.platform].get_transaction_status(order.reference)
```

## Errors

All exceptions derive from `momo_openapi.MomoError`:

- `ConfigurationError`: a required setting is missing, such as currency or credentials.
- `AuthenticationError`: the token endpoint answered 401. With `code == "invalid_client"`, the platform doesn't recognise the User ID / Credential Token pair: check it was created under **System credentials** for the right wallet, and that it belongs to the platform you're calling. A 401 that mentions the subscription key is about `Ocp-Apim-Subscription-Key`.
- `MomoAPIError`: any other non-success response, including a 400, 403 or 5xx from the token endpoint. It carries `status_code`, `body` and `code`, which is MoMo's `code` (e.g. `RESOURCE_NOT_FOUND`) or OAuth's `error` (e.g. `invalid_client`). Gateway HTML error pages are shortened to their title in the message.

```python
from momo_openapi import MomoAPIError

try:
    collection.get_transaction_status(reference)
except MomoAPIError as exc:
    if exc.code == "RESOURCE_NOT_FOUND":
        ...  # unknown reference, or it was created on the other platform
```

## Sandbox

The sandbox at `sandbox.momodeveloper.mtn.com` is not part of the migration. There you create an API user and key yourself:

```python
from momo_openapi import Collection

collection = Collection.for_sandbox("YOUR_COLLECTION_SUBSCRIPTION_KEY")
reference = collection.request_to_pay("100", "46733123453", external_id="test-1")
```

`provision_sandbox_user(subscription_key)` returns the `(user_id, api_key)` pair if you'd rather store it.

## Upgrading from the old `mtnmomoapi` module

| Old | New |
|---|---|
| `Collection()` | `Collection.from_env()` |
| `requestToPay(amount, phone, external_id, payernote, payermessage)` | `request_to_pay(amount, phone, external_id, payer_message=..., payee_note=...)` returns the reference ID |
| `transfer(amount, phone, external_id, payermessage, payermessageNone)` | `transfer(amount, phone, external_id, payer_message=..., payee_note=...)` returns the reference ID |
| `getTransactionStatus(ref)` | `get_transaction_status(ref)` returns MoMo's JSON |
| `getBalance()` / `authToken()` | `get_balance()` / `get_access_token()` |
| Failures returned a status code or raised `Exception` | Failures raise `MomoAPIError` |

Clients no longer create an API user on construction. Production credentials come from the Partner Portal, and for the sandbox you call `for_sandbox()`.

The old module built its Basic header with `basicauth.encode()`, which URL-encodes the user ID and key. That was harmless with UUIDs and hex keys, but the new platform's gateway answers 500 to it. It also sent the `python-requests` User-Agent and used a 3-second timeout, while the new platform currently takes 10–20 seconds to answer a token request. Patching the old module only for the new base URL and header is therefore not enough.

## Development

```bash
uv venv && uv pip install -e ".[dev]"
.venv/bin/pytest
```

[examples/](examples/README.md) has one script per platform and operation, in `legacy/` and `new/`: check credentials, balance, collect, disburse and remit, and the status of each payment. They read `.env` and double as manual checks. They are not part of the published package. `collect`, `disburse` and `remit` move real money.

```bash
.venv/bin/python examples/new/check_credentials.py
.venv/bin/python examples/legacy/collect.py 100 --ref swap-123
.venv/bin/python examples/legacy/collect_status.py <reference>
.venv/bin/python examples/new/disburse.py 30000 --ref payout-7
```

### Releasing

[.github/workflows/publish.yml](.github/workflows/publish.yml) publishes to PyPI with [Trusted Publishing](https://docs.pypi.org/trusted-publishers/), so no API token is stored anywhere.

Every push to `main` runs it. Releases are versioned automatically by [python-semantic-release](https://python-semantic-release.readthedocs.io/) from [Conventional Commits](https://www.conventionalcommits.org/) since the last `v*` tag, so don't edit `__version__` or [CHANGELOG.md](CHANGELOG.md) by hand.

| Commit | Release |
| --- | --- |
| `fix: ...`, `perf: ...` | patch, 0.1.0 → 0.1.1 |
| `feat: ...` | minor, 0.1.0 → 0.2.0 |
| `!` after any type (`feat!: ...`) or a `BREAKING CHANGE:` footer | minor while on 0.x, major from 1.0.0 |
| `docs:`, `refactor:`, `test:`, `ci:`, `chore:`, ... | none on its own |

The workflow runs the tests and checks the build. If a release is due, it bumps `__version__` in [src/momo_openapi/_version.py](src/momo_openapi/_version.py), adds the commit subjects to the changelog, commits `chore(release): <version>`, tags `v<version>`, creates the GitHub release and uploads to PyPI through the `pypi` environment. If that environment requires a reviewer, the upload waits for approval. Commit subjects become changelog entries, so write them for users. To release 1.0.0, set `major_on_zero = true` under `[tool.semantic_release]` in [pyproject.toml](pyproject.toml) and push a breaking change.

To preview the next version locally: `uvx --from python-semantic-release==10.7.0 semantic-release version --print`.

## License

MIT © Dali Hillary
