How to receive Paypal webhooks on localhost

By Karolis Rusenas · Aug 21, 2018

PayPal webhooks

In this article, we will write a small web server in my favorite language Go that handles PayPal’s webhooks. I will show you how easy it is to start receiving webhooks, developing your application and debugging it with Webhook Relay.

According to PayPal webhook docs:

Webhooks are HTTP callbacks that receive notification messages for events. To create a webhook at PayPal, users configure a webhook listener and subscribe it to events. A webhook listener is a server that listens at a specific URL for incoming HTTP POST notification messages that are triggered when events occur. PayPal signs each notification message that it delivers to your webhook listener.

So, nothing unusual or new. Webhooks are important - they are used in pretty much any SaaS application that has a subscription model. With webhooks you can:

  • Enable/disable features when a customer pays for the plan.
  • Email a plan change confirmation to your customer.

No time to explain, let’s get to it

  • Download CLI and register to get your key & secret. Instructions can be found here. If you have done that already (I guess most of the planet have already registered), you are good to go to the next step!
  • Download and install Go, instructions here. Unlike other languages that install an astronomical number of packages, using Go you can usually get around with just a handful of helper packages.
  • PayPal (probably no surprise here) - we will be using developer dashboard.

First things first, create a webhook. Go to the PayPal Dashboard and create an app, then select the app in which you want to enable webhooks:

create PayPal app

Now, let’s get our public Webhook Relay endpoint. Assuming that our future app will accept webhooks on http://localhost:8080/v1/paypal-webhooks, start forwarding them there:

$ relay forward -b paypal http://localhost:8080/v1/paypal-webhooks
Forwarding: 
https://my.webhookrelay.com/v1/webhooks/672d0c8f-b742-4a99-96c1-af4de65bc02a -> http://localhost:8080/v1/paypal-webhooks
Starting webhook relay agent... 
1.5345938246082163e+09    info    webhook relay ready...    {"host": "my.webhookrelay.com:8080"}

Here we can see https://my.webhookrelay.com/v1/webhooks/672d0c8f-b742-4a99-96c1-af4de65bc02a (you will have a different ID:), this is our public endpoint which we can supply to PayPal:

paypal webhook endpoint

Let’s tick “All events” as we really don’t care about it now, we can always un-tick boxes. Scroll to the bottom and click “Save”.

Let’s Go

Time to write some Go. I have started this article thinking it’s going to be about PayPal’s IPNs but turns out it’s about webhooks, I will write another one about IPNs later one.

We will create a package that will be handling our webhooks.

package ipn

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "time"
)

type Listener struct {
    debug bool
}

func New(debug bool) *Listener {
    return &Listener{
        debug: debug,
    }
}

// Listen for webhooks
func (l *Listener) WebhooksHandler(cb func(err error, n *PaypalNotification)) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        body, err := ioutil.ReadAll(r.Body)
        if err != nil {
            cb(fmt.Errorf("failed to read body: %s", err), nil)
            return
        }

        var notification PaypalNotification
        err = json.Unmarshal(body, &notification)
        if err != nil {
            cb(fmt.Errorf("failed to decode request body: %s", err), nil)
            return
        }

        if l.debug {
            fmt.Printf("paypal: body: %s, parsed: %+v\n", body, notification)
        }

        w.WriteHeader(http.StatusOK)
        cb(nil, &notification)
    }
}

type PaypalNotification struct {
    ID           string    `json:"id"`
    CreateTime   time.Time `json:"create_time"`
    ResourceType string    `json:"resource_type"`
    EventType    string    `json:"event_type"`
    Summary      string    `json:"summary"`
    Resource     struct {
        ParentPayment string    `json:"parent_payment"`
        UpdateTime    time.Time `json:"update_time"`
        Amount        struct {
            Total    string `json:"total"`
            Currency string `json:"currency"`
        } `json:"amount"`
        CreateTime time.Time `json:"create_time"`
        Links      []struct {
            Href   string `json:"href"`
            Rel    string `json:"rel"`
            Method string `json:"method"`
        } `json:"links"`
        ID    string `json:"id"`
        State string `json:"state"`
    } `json:"resource"`
    Links []struct {
        Href    string `json:"href"`
        Rel     string `json:"rel"`
        Method  string `json:"method"`
        EncType string `json:"encType"`
    } `json:"links"`
    EventVersion string `json:"event_version"`
}

and then the code to put our application logic looks like:

package main

import (
    "log"
    "net/http"

    ipn "github.com/webhookrelay/paypal-ipn"
)

const (
    debug = true
)

func main() {
    mux := http.NewServeMux()

    listener := ipn.New(debug)

    mux.Handle("/v1/paypal-webhooks", listener.WebhooksHandler(func(err error, n *ipn.PaypalNotification) {
        if err != nil {
            log.Printf("IPN error: %v", err)
            return
        }

        log.Printf("event type: %s", n.EventType)
        log.Printf("event resource type: %s", n.ResourceType)
        log.Printf("summary: %s", n.Summary)
    }))
    log.Println("server starting on :8080")
    log.Fatalf("failed to run http server: %v", http.ListenAndServe(":8080", mux))
}

Code can be found here: https://github.com/webhookrelay/paypal-ipn. To start our app, we simply:

$ go run example/main.go
2018/08/18 20:01:53 server starting on :8080

In real life, you would probably want to use go install (makes builds a lot faster).

Enter the simulation

Go to Webhooks Simulator under MOCK section. Now enter again our public Webhook Relay endpoint and let’s select “Payment capture completed”:

webhooks simulator

Click “Send Test”.

In a few seconds we should see in our app received webhook:

$ go run example/main.go
2018/08/18 20:01:53 server starting on :8080
2018/08/18 20:02:10 event type: PAYMENT.CAPTURE.COMPLETED
2018/08/18 20:02:10 event resource type: capture
2018/08/18 20:02:10 summary: Payment completed for $ 7.47 USD

Congrats, we just earned 7.47 imaginary USD!

P.S. Not sure why, but it sometimes takes more time for them to send those dummy webhook so if nothing happens after you click that button, just wait a bit. Or once you have the webhook already in Webhook Relay, you can retry from there, they get forwarded instantly.

Debugging webhooks

We can also see all webhooks that are sent through the Wehboook Relay in our buckets page:

paypal webhook

You can inspect and resend webhooks from here. It’s also a good place to get the initial JSON structure and convert it to Go struct using this nice website: https://mholt.github.io/json-to-go/.

Wrapping up

Webhook Relay makes it really straightforward to receive webhooks on localhost or private networks. It can be used not only in development but in production as well if you don’t want or can’t expose your webhooks processing server to the internet.

If you are working with Stripe, check out my previous blog post here.