PayPal for SaaS: 99 problems

Say you have a SaaS business. You're happily using Stripe to charge customers. But some people (particularly consumers) keep asking you to let them pay with PayPal; Either because they don't have a credit card or because they don't want to trust you with their data. That's bad news. What took you hours to set up in Stripe will now take you days with PayPal. This is the story of how you'll be spending those days.

First, you'll go to PayPal's Developer site. It's titled "PayPal Developer Experience". You will eventually find that the choice of the word "experience" is surprisingly accurate.

The Developer page presents you with a few options. None of them are actually what you want – but there is no way for you to realise that now. "Express Checkout" sounds good. "Braintree Direct" supposedly lets you "start accepting payments with just a snippet of code". Sounds great too! But you don't know who or what Braintree is, so decide to go with the former.

Express Checkout

PayPal's Express Checkout site says:

Express Checkout is a solution for merchants who currently accept credit card payments online and would like to add PayPal as a payment option.

Describes our requirement perfectly! So you read a few docs and find out that it's basically like Stripe's Checkout. It opens a popup where your customers can authorize the payment:

Except that unlike Stripe, which lets you open the popup whenever it makes sense in your checkout process, PayPal legally requires you to place this beauty of a button (or one of its close variants) on your page:

So with Express Checkout, you can't use your own buttons. You must use the ones provided by PayPal.

"Okay, no big deal", you think and create a first prototype. The examples provided by PayPal are all for single (ie. non-recurring) payments. To keep it simple and get the prototype up and running quickly, you decide to follow the examples for now and worry about recurring payments later.

Everything's a little more cumbersome than in Stripe. For instance: Stripe gives you two API keys, one for testing and one for production. Depending on which key you use, Stripe knows if it's a live payment. PayPal on the other hand always requires you to supply both keys and then set a special variable that indicates which one you want to use:

    env: 'production', // Or 'sandbox'
    client: {
        sandbox:    '...',
        production: '...'

This means that your test environment must know your production key and vice versa. I'm not huge on security but it doesn't sound great. And it's certainly more repetitive than it should be.

Another, maybe more serious security issue: PayPal suggests the following code for executing a payment:

      onAuthorize: function(data, actions) {
          return actions.payment.execute().then(function(payment) {
              // The payment is complete!
              // You can now show a confirmation message to the customer

Note that this all happens on the client. In other words, PayPal's onAuthorize callback makes you believe that the payment was successfully executed. But what if the customer simply executes the "success" JavaScript code manually? If you don't want to open yourself up to such "fake" purchases, your app must confirm each payment with PayPal's servers. The docs make no mention of that.

Anyways, you realise all that and after maybe two to three hours in total, your prototype happily processes payments in a test environment. Time to generalise it for recurring payments.

Except, it's not possible.

Express Checkout only supports one-time payments. It's not that PayPal can't do subscriptions or that the necessary calls would be that different from those for single payments. They're simply not implemented in the Express Checkout API. A few "experiences" the wiser, you're back to square one.


PayPal's REST API supports recurring payments. It too is more cumbersome to use than Stripe's. There are official Python bindings. That's nice. But they feel more like a computer-generated wrapper than a native implementation. Everything requires or returns dictionaries. For instance:

    "mode": "live", # sandbox or live
    "client_id": "...",
    "client_secret": "..."

Why not simply:

    client_secret: "..."

Okay, no big deal. Similarly: Did you spot the little inconsistency? For Express Checkout above, we had to supply the env: 'production' parameter to indicate whether we are in a testing environment. Now, the parameter is 'mode': 'live' – same meaning but different names. It's little things like this that keep you busy in the days you're wrestling with PayPal.

There a few more gems to complete the story. If you've seen enough of the creativity of PayPal's developers, skip to the Summary.

Executing a single payment via PayPal requires a surprising number of steps:

  1. Create a Payment object. You have to pass a dictionary with lots of arcane values.
  2. Call payment.create(). This contacts PayPal's servers and sets an approval_url hidden deep in a dictionary/list of the payment object. You can't just access this variable. You have to iterate over the list to see if the approval_url is actually there.
  3. Redirect the customer to the approval URL.
  4. After the customer has approved the payment, PayPal redirects her to a return_url which you specified when creating the Payment object. It doesn't seem to be mentioned in the docs, but PayPal appends ?token=... to the URL to let you identify the payment.
  5. Retrieve the payment via the token and call payment.execute().

Stripe can do the above in two steps. Why so complicated, PayPal?

It's even worse for subscriptions. You can't just say "Charge this customer $10 per month". You have to create a Billing Plan, which is like a template for a subscription contract. Then you have to create a Billing Agreement which references the Plan and is like a signed copy of the contract. Now suppose you want to offer varying discounts. You can't just store the amount to be paid in the Billing Plan, because it will differ for each customer. So you have to store it in the Billing Agreement. But then, what's the point of having a Billing Plan? It's not like it lets you say "now change the subscription price of all existing customers". Because Billing Plans can't be changed once they're active.

Think we're done? There's more. Error handling! Ideally, you'd like one way – and only one way – of finding out whether an error occurred. At least in the official Python SDK, PayPal forces you to handle errors in two different ways:

    agreement = BillingAgreement.execute(token)
except ConnectionError as e:
    # Handle connection error
if agreement.error:
    # Handle other errors.

Why, PayPal, why? Things like this take trial and error to figure out and are completely unnecessary.


It's no wonder that Stripe is so successful, given that PayPal takes an order of magnitude longer to set up. Where PayPal still has the edge is brand recognition and trust among consumers – deserved or not. If you are selling to consumers, it is likely that accepting PayPal will boost your sales. Otherwise, stay away from them. They're not deserving of your business.