There's this cool Go project called payment-rails. The vision is pretty simple: to develop an SDK for all the payment gateways in Africa. It's still in the early stages but the progress is rapid. If you're working with payments integration you should definitely check it out, we may have something for you.

This is the first OSS project I have contributed to and it was a very wholesome experience. When your work is going to be used and/or scrutinized by others it's a different kind of pressure. I am always critical of open source projects I use so I should know. In this article I will talk about the design choices I made when I built the SDK. Their API design is very interesting to say the least. So make yourself comfortable and let's get started. Hopefully we learn thing or two by the time we're done.

Before we continue, you can find the MTN MoMo API docs here and the SDK client I built here.
A quick look at the MTN MoMo docs and you'll notice their APIs are grouped into 3 products:

  • Collection
  • Disbursement
  • Remittance
You need an API key and secret to authenticate with the API. To use any of the products above you need to subscribe to it separately and get a subscription key that should be passed in all requests to that product API. To put simply, there are two credentials used for authentication, the API key/secret and the product subscripion key, where each product has its own subscription key.
Grouping the API into products has its benefits; the main one being related APIs come bundled together and users only subscribe to the product they wish to use; for example if my uses revolve around receiving payments from customers then I only need to subscribe to the Collection product. From a security perspective, even if your subscription key got leaked the bad actor cannot use it with Disbursement or Remittance APIs. This is a good principle that I wanted to mirror in my SDK at all costs. The only problem I had with this is that some APIs operations are common across all the products i.e. APIs for getting auth tokens and getting user details, which ultimately led to some violations of the DRY principle but we can live with that.

The SDK structure is shown below. The collection package has methods for all Collection product APIs, same for disbursement and remittance packages.

	momo
	  |__collection
	  |__common
	  |__disbursement
	  |__remittance
	momo.go
The file momo.go is where the client the user applications interfaces with is defined. A brief overview of its contents are shown below:
	
	type ClientConfig struct {
	    Environment string
	    APIKey string
	    APISecret string
	    CollectionSubscriptionKey string
	    DisbursementSubscriptionKey string
	    RemittanceSubscriptionKey string
	    HTTPClient *http.Client
	}

	type Client struct {
	    Collection collection.Service
	    Disbursement disbursement.Service
	    Remittance remittance.Service
	}

	func New(cfg ClientConfig) (*Client, error) {}
	
In most practical uses, when creating the SDK client you could pass subscription keys for any one of, two or all three products: Collection, Remittance or Disbursement, and only products whose subscription keys have been passed will be initialized. For example passing the CollectionSubscriptionKey only will return a client with only the Collection initialized, the rest will be nil. If no subscription key is passed for any of the products then a client is created where Collection, Disbursement and Remittance will all be nil. The types collection.Service, disbursement.Service and remittance.Service are interfaces for the methods provided by the SDK and consumed by the user application, they are pretty much a one-to-one mapping to the Momo MTN APIs.
Lastly as I wrap up this section, when initializing the SDK you pass the HTTP client to use, for example one with tracing middleware. If none is passed the default Go HTTP client is used. By design, it's not possible --and neither sensible, in my opinion-- to change the HTTP client once the SDK has been initialized.

One of the expectations users of this SDK will have is that they can initialize the client with a specific product and that product's methods work end-to-end. For example the Collection, Disbursement and Remittance types contain methods for all APIs in the respective products. If you look at the API docs for MTN MoMo you'll notice there's a number of repeated methods; e.g. methods for token management and user info are duplicated across all the three product APIs. It's just the side effect of designing the API around products.
It was obvious mirroring my SDK to their API design would result in that repetition leaking into my code, which was unpleasant to say the least. The approach I ended up taking was inspired by the stripe-go client.
When you look at it from the top down, there's a boundary where all SDK methods converge and start doing the same thing; building a request, making a network call and handling the response. Before this point there's different logic done based on each request, most commonly validating the inputs and building headers. The solution I used builds on this and abstracts away the common work behind a common interface. For more details checkout the common package.

	
	type Params struct {
	    Path []string
	    Query map[string]string
	}

	type Backend interface {
	    Call(
               ctx context.Context,
               method, path string,
               headers http.Header,
               params *Params,
               body, result any
	    ) error
	}

	type BackendImpl struct {
	    url string
	    HTTPClient *http.Client
	}

	func (b *BackendImpl) Call(
	    ctx context.Context,
	    method, path string,
	    headers http.Header,
	    params *Params,
	    body, result any)
	error {
	  // Backend interface implementation
	}

	type BackendConfig struct {
	    Environment string
	    HTTPClient *http.Client
	}

	func NewBackend(config *BackendConfig) (Backend, error) {
	  // return a concrete type of type Backend
	}
	
Let's walk over the constructs above one by one.
Params is the type used to pass path and query parameters. Path paramters are passed as a slice of strings and the order of the strings is the order of the path parameters. Query parameters are passed in the map as key value pairs; they key is query key and the value is query value.
Backend interface is the real abstaction over the netwrok calls and all that it involves. It only has one method Call that takes all necessary arguments and returns error which will be nil if the call was successful otherwise it contains reason why the call failed.
BackendImpl type satisfies the interface above and has other utility receiver methods. The url type holds the base url which could point to either the production or sandbox environment. The HTTPClient is the client used to make requests an is configurable to whatever the user may pass in.
The Call receiver method on BackendImpl does a number of things:
  • marshal the body argument into a json object
  • builds a new request using another receiver method on BackendImpl
  • does the actual http request
  • handles response errors if any otherwise decodes the response into the result argument
BackendConfig is a convinience type used by NewBackend to pass config options used to create a Backend. In hindsight the BackendConfig type is really needless, I could just pass two arguments to the NewBackend function.

That's how I abstracted away and centralized the logic for making HTTP requests. The backend is passed as an argument into the constructor methods for Collection, Disbursement and Remittance. From there it is accessible to all their methods too. A typical SDK method only needs to build headers, validate the input and call the backend with the necessary arguments.

So there it is, my approach to building the SDK for MTN MoMo. I've glossed over many details here (especially regarding the code) but I have touched on all things I felt are important. The docs are pretty comprehensive and explain most of the nuances in details so you can refer there if you are curious about something. Improvements and corrections are always welcome so feel free to open issues/prs if spot a bug or have improvements.