skip to Main Content

I am working to validate Paypal webhook data but I’m running into an issue where it’s always returning a FAILURE for the validation status. I’m wondering if it’s because this is all happening in a sandbox environment and Paypal doesn’t allow verification for sandbox webhook events? I followed this API doc to implement the call: https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature

Relevant code (from separate elixir modules):

def call(conn, _opts) do
  conn
    |> extract_webhook_signature(conn.params)
    |> webhook_signature_valid?()
    |> # handle the result
end

defp extract_webhook_signature(conn, params) do
  %{
    auth_algo: get_req_header(conn, "paypal-auth-algo") |> Enum.at(0, ""),
    cert_url: get_req_header(conn, "paypal-cert-url") |> Enum.at(0, ""),
    transmission_id: get_req_header(conn, "paypal-transmission-id") |> Enum.at(0, ""),
    transmission_sig: get_req_header(conn, "paypal-transmission-sig") |> Enum.at(0, ""),
    transmission_time: get_req_header(conn, "paypal-transmission-time") |> Enum.at(0, ""),
    webhook_id: get_webhook_id(),
    webhook_event: params
  }
end

def webhook_signature_valid?(signature) do
  body = Jason.encode!(signature)
  case Request.post("/v1/notifications/verify-webhook-signature", body) do
    {:ok, %{verification_status: "SUCCESS"}} -> true
    _ -> false
  end
end

I get back a 200 from Paypal, which means that Paypal got my request and was able to properly parse it and run it though its validation, but it’s always returning a FAILURE for the validation status, meaning that the authenticity of the request couldn’t be verified. I looked at the data I was posting to their endpoint and it all looks correct, but for some reason it isn’t validating. I put the JSON that I posted to the API (from extract_webhook_signature) into a Pastebin here cause it’s pretty large: https://pastebin.com/SYBT7muv

If anyone has experience with this and knows why it could be failing, I’d love to hear.

2

Answers


  1. Chosen as BEST ANSWER

    I solved my own problem. Paypal does not canonicalize their webhook validation requests. When you receive the POST from Paypal, do NOT parse the request body before you go to send it back to them in the verification call. If your webhook_event is any different (even if the fields are in a different order), the event will be considered invalid and you will receive back a FAILURE. You must read the raw POST body and post that exact data back to Paypal in your webhook_event.

    Example: if you receive {"a":1,"b":2} and you post back {..., "webhook_event":{"b":2,"a":1}, ...} (notice the difference in order of the json fields from what we recieved and what we posted back) you will recieve a FAILURE. Your post needs to be {..., "webhook_event":{"a":1,"b":2}, ...}


  2. For those who are struggling with this, I’d like to give you my solution which includes the accepted answer.

    Before you start, make sure to store the raw_body in your conn, as described in Verifying the webhook – the client side

    
      @verification_url "https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature"
      @auth_token_url "https://api-m.sandbox.paypal.com/v1/oauth2/token"
    
     defp get_auth_token do
        headers = [
          Accept: "application/json",
          "Accept-Language": "en_US"
        ]
    
        client_id = Application.get_env(:my_app, :paypal)[:client_id]
        client_secret = Application.get_env(:my_app, :paypal)[:client_secret]
    
        options = [
          hackney: [basic_auth: {client_id, client_secret}]
        ]
    
        body = "grant_type=client_credentials"
    
        case HTTPoison.post(@auth_token_url, body, headers, options) do
          {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
            %{"access_token" => access_token} = Jason.decode!(body)
            {:ok, access_token}
    
          error ->
            Logger.error(inspect(error))
            {:error, :no_access_token}
        end
      end
    
      defp verify_event(conn, auth_token, raw_body) do
        headers = [
          "Content-Type": "application/json",
          Authorization: "Bearer #{auth_token}"
        ]
    
        body =
          %{
            transmission_id: get_header(conn, "paypal-transmission-id"),
            transmission_time: get_header(conn, "paypal-transmission-time"),
            cert_url: get_header(conn, "paypal-cert-url"),
            auth_algo: get_header(conn, "paypal-auth-algo"),
            transmission_sig: get_header(conn, "paypal-transmission-sig"),
            webhook_id: Application.get_env(:papervault, :paypal)[:webhook_id],
            webhook_event: "raw_body"
          }
          |> Jason.encode!()
          |> String.replace(""raw_body"", raw_body)
    
        with {:ok, %{status_code: 200, body: encoded_body}} <-
               HTTPoison.post(@verification_url, body, headers),
             {:ok, %{"verification_status" => "SUCCESS"}} <- Jason.decode(encoded_body) do
          :ok
        else
          error ->
            Logger.error(inspect(error))
            {:error, :not_verified}
        end
      end
    
      defp get_header(conn, key) do
        conn |> get_req_header(key) |> List.first()
      end
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search