Automate PayPal webhook updates with ngrok for local development

Introduction

I have been working with PayPal integrations recently. When building and testing, there is a recurring pain point: PayPal must be able to reach the application with a public HTTPS URL to deliver webhook events. However the application is running on localhost, behind a private network. That usually means:

  • Manually starting ngrok
  • Copying the forwarded URL
  • Logging in to the PayPal dashboard
  • Editing and saving the webhook target

Then doing those steps all over again the next time ngrok restarts with a new URL.

I had the opportunity to create a shell script that automates that entire workflow. Here is what it does in a nutshell:

  • Starts ngrok
  • Waits until the tunnel is ready
  • Retrieves the public URL from the ngrok local API
  • Updates the webhook via the PayPal API. This way events are forwarded to the local machine without any manual work required.

It also validates a few prerequisites, and terminate the ngrok process when done. For people working frequently with PayPal webhooks in development, this script should save some time, prevent any copy-paste errors (I can vouch for those), and make the developer experience more smooth overall.

Note: This script is mainly intended for developers who are committed to using ngrok as their tunnelling solution. If you are open to alternatives (self-hosted or other tools), you could consider another approach. I described one in a previous article: Running your own ngrok with Docker

What you’ll need

  • Ngrok - it goes without saying.
  • JQ installed. Jq is useful to parse and generate JSON cleanly in shell. This is necessary for reading values from API responses, and also to construct the request body for the PayPal webhook update.
  • curl to fire HTTP requests. Unless you are running on a minimal Linux distribution, this should be installed by default.
  • Your PayPal sandbox API client and secret, as well as the webhook id. We can find those at https://developer.paypal.com/dashboard/. The webhook will have to be already configured with the events you want to listen to.

Let’s get started!

Note: Those articles are a way for me to learn as much as to share my knowledge. If you are ony interested in the solution, feel free to get it on github directly instead! Alternatively, we can also create a script that creates the webhook from scratch, and delete it when the script terminates. Take a look at the following script if that’s what you are looking for.

Creating the script

Verifying requirements

Let’s create a function that will check if our required dependencies are available.

checkForDependencies() {
  if ! command -v jq >/dev/null 2>&1; then
    echo "❌ Error: 'jq' is not installed. Please install it first." >&2
    echo "   macOS: brew install jq" >&2
    echo "   Ubuntu/Debian: sudo apt install jq" >&2
    exit 1
  fi

  if ! command -v ngrok >/dev/null 2>&1; then
    echo "❌ Error: 'ngrok' `is not installed. Please install it first." >&2
    echo "   Install it from https://ngrok.com/download" >&2
    exit 1
  fi
}
  • command -v is a POSIX-compliant way to test for command availability. It returns the path to the executable if found, or nothing if not found.
  • >&2 is used to redirect the logs in stderr, so we can log those lines as errors.

Retrieving the PayPal access token

The PayPal documentation give us an example: https://developer.paypal.com/reference/get-an-access-token/. Here is how to translate it in bash:

getPayPalAccessToken() {
  ACCESS_TOKEN=$(curl \
    -s \
    -u "$PAYPAL_API_CLIENT_ID:$PAYPAL_API_SECRET" \
    -d "grant_type=client_credentials" \
    "https://api-m.sandbox.paypal.com/v1/oauth2/token" | jq -r '.access_token')

  if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then
    echo "❌ Failed to retrieve PayPal access token. Check credentials or network."
    exit 1
  fi
}

Here we are retrieving a OAuth 2.0 access token from PayPal’s sandbox API.

  • the -s flag suppresses the progress bar.
  • the -u flag provides HTTP basic authentication using the PayPal API credentials.
  • the -d parameter sends form data with the grant type “client_credentials”. This is the OAuth flow for server-to-server authentication where no user interaction is required.
  • jq is used to parse the JSON result, so we can extract the token (since that’s the only thing we are interested in). Typically, the response will include an access token that looks something like this:
    {
    "access_token": "A21AAFEjXXXXXXXX...",
    "token_type": "Bearer",
    "expires_in": 32400,
    "app_id": "...."
    }
    

    Without the -r flag, jq would return “A21AAFEjXXXXXXXX…” (with quotes), but with -r, we get just A21AAFEjXXXXXXXX… as plain text. This format makes it easy to assign it to variables in the script.

Starting ngrok

startNgrok() {
  echo "Starting ngrok..."

  ngrok http --host-header=my-application.local https://my-application.local > "$LOG_FILE" 2>&1 &
  NGROK_PID=$!

  trap 'echo "🧹 Cleaning up: killing ngrok process (PID $NGROK_PID)..."; kill "$NGROK_PID" >/dev/null 2>&1 || true' EXIT

  echo "Waiting for ngrok to start..."
  until curl -s --fail http://127.0.0.1:4040/api/tunnels > /dev/null; do
    sleep 0.5
  done

  # Extra wait to ensure tunnels are ready
  sleep 0.5

  echo "ngrok is ready!"
}

This function launches ngrok in the background, captures its PID for cleanup on exit, then waits until ngrok’s local API is reachable to ensure the tunnel is fully up before the script continues. It also logs ngrok’s output to a file for debugging.

Getting the ngrok public URL.

getNgrokPublicUrl() {
  NGROK_PUBLIC_URL=$(curl -s http://127.0.0.1:4040/api/tunnels | grep -Eo 'https://[^"]+' | head -n 1)

  echo "Public URL: $NGROK_PUBLIC_URL"
}

Fortunately for us, ngrok comes with a local api that we can use to retrieve the tunnel information. The format is XML so we can use grep to extract the string we are look for, using the “https” keyword.

  • The grep command enables extended regular expressions (-E), which allows more powerful pattern matching syntax.
  • -o tells grep to output only the matching portions of lines, rather than entire lines that contain matches.
  • The pattern ‘https://[^”]+’’ matches any string starting with “https://” followed by one or more characters that aren’t double quotes. This captures the full HTTPS URL from the JSON response.
  • To finish, the code snippet head -n 1 is used to display only the first line of input. This is because grep could potentially return multiple results, if more than 1 url is present in the results.

Updating the PayPal webhook URL

Almost there! Our last step will be to update the webhook with the ngrok URL. Here is the PayPal documentation about this feature: https://developer.paypal.com/docs/api/webhooks/v1/#webhooks_update

updatePayPalWebhookUrl() {
  local paypal_webhook_url="${NGROK_PUBLIC_URL}/paypal-webhook"
  echo "Updating PayPal webhook ($PAYPAL_WEBHOOK_ID) to: $paypal_webhook_url"

  local body=$(jq -n --arg url "$paypal_webhook_url" '[{op:"replace", path:"/url", value:$url}]')

  local response_file="$(mktemp)"
  local http_code=$(curl -sS -o "$PATCH_RESP_FILE" -w "%{http_code}" -X PATCH \
    "https://api-m.sandbox.paypal.com/v1/notifications/webhooks/$PAYPAL_WEBHOOK_ID" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $ACCESS_TOKEN" \
    -d "$body")

  if [ "$http_code" = "200" ]; then
    echo "Webhook updated successfully. Run Ctrl+C to terminate."
  elif [ "$http_code" = "204" ]; then
    echo "Webhook updated successfully (no content returned)."
  else
    echo "Failed to update webhook. HTTP $http_code"
    echo "Server response:"
    jq . < "$response_file" || cat "$response_file"
    rm -f "$response_file"
    exit 1
  fi
}

Jq comes in handy here:

  • jq -n creates a new JSON document from scratch (the -n flag means “null as an input”). This allows us to generate JSON without requiring an external input file or piped data.
  • The –arg url “$PAYPAL_WEBHOOK_URL” part passes the value of the PAYPAL_WEBHOOK_URL environment variable into jq as a variable named url.

The curl command uses several important curl options:

  • -sS enables silent mode while still showing errors
  • -o "$PATCH_RESP_FILE" saves the response body to a file
  • -w "%{http_code}" outputs only the HTTP status code to stdout The combination allows the script to capture the response content and status code separately. That’s useful here since we want to programmatically process the API response. It makes it easier to show errors and debug if needed.

Final script

We can now put everything together. The only addition being to wait for the ngrok process to terminate. If we don’t, nothing will stop the script from exitting.

The final script is available on github here.

If you found this tutorial helpful, star the repo as a thank you! ⭐

Going further

Something that can comes really handy is to retrieve and/or update the credentials fom a .env file. Here are a few functions you can use:

ENV_FILE="$HOME/path/to/.env"

getPayPalEnvVars() {
  PAYPAL_API_CLIENT_ID=$(grep -E '^PAYPAL_API_CLIENT_ID=' "$ENV_FILE" | cut -d '=' -f2-)
  PAYPAL_API_SECRET=$(grep -E '^PAYPAL_API_SECRET=' "$ENV_FILE" | cut -d '=' -f2-)

  if [ -z "$PAYPAL_API_CLIENT_ID" ] || [ -z "$PAYPAL_API_SECRET" ]; then
    echo "Missing PAYPAL_API_CLIENT_ID or PAYPAL_API_SECRET in $ENV_FILE"
    exit 1
  fi

  if [ -z "${PAYPAL_ORIGINAL_WEBHOOK_ID:-}" ]; then
    echo "Missing PAYPAL_WEBHOOK_ID in $ENV_FILE"
    exit 1
  fi
}

updatePayPalWebhookIdInEnvFile() {
  if sed --version >/dev/null 2>&1; then
    # GNU sed (Linux)
    sed -i -E "s|^PAYPAL_WEBHOOK_ID=.*|PAYPAL_WEBHOOK_ID=${PAYPAL_WEBHOOK_ID}|" "$ENV_FILE"
  else
    # BSD sed (macOS)
    sed -i '' -E "s|^PAYPAL_WEBHOOK_ID=.*|PAYPAL_WEBHOOK_ID=${PAYPAL_WEBHOOK_ID}|" "$ENV_FILE"
  fi

  echo "PAYPAL_WEBHOOK_ID updated in .env file"
}

Feel free to add them to the mix!