Integrate webhooks
This guide covers technical details for implementing a webhook listener and verifying messages received.
Subscribing a listener URL
There are two ways to subscribe a URL to webhook events:
- In Application management, under each App's settings
- Programmatically using the Webhooks Management API
When you subscribe a listener URL, it will have a Webhook ID. Record the ID as it is needed for verification of all webhook messages sent to that URL.
Your listener should be an app or script deployed at the corresponding URL that listens on HTTPS port 443. For webhook message deliveries to be successful, the listener must respond with an HTTP 2xx success status every time one is posted. Otherwise—for example if there is no response, PayPal cannot connect, or if the server responds with a 404 or 500 error—PayPal will retry each delivery up to 25 times over 3 days.
Test the listener with mock events
You may use the Webhooks simulator to generate mock events. These do not belong to any App and can be generated on demand purely for development and testing purposes, to validate the functionality of a listener and its ability to receive and verify a webhook cryptographically. (Note: Postback verification to the verify-webhook-signature endpoint is not supported for mock events.) For message signature verification purposes, the ID of mock webhooks is the string:WEBHOOK_ID
.Message verification
If your system is programmatically capable, self cryptographic verification is the preferred method as it will be faster and avoid extra API dependency and latency. The alternative (not usable for mock test webhooks) is to post the same information back to PayPal and have it perform the verification for you.
Self verification method
Form the following original message string for a signature check:
1transmissionId|timeStamp|webhookId|crc32
The fields in the string are:
Field | Description |
---|---|
transmissionId | The unique ID of the transmission, from the paypal-transmission-id HTTP header. |
timeStamp | The date and time when the message was transmitted, from the paypal-transmission-time HTTP header. |
webhookId | The ID of the webhook from when the listener URL subscribed to events. This does not come in the header or body of the message but rather where the webhook listener URL's event subscriptions are configured, such as Application management |
crc32 | The Cyclic Redundancy Check (CRC32) checksum for the body of the HTTP payload, in decimal form. You must use the original raw body to calculate this; do not parse the body to an array/object and then re-stringify it. |
paypal-cert-url
. This file should automatically be downloaded and cached for future use. Use it to validate the signature given by the header paypal-transmission-sig
for the original message string.Sample code for a listener that does the above:
1import "dotenv/config";2import express from "express";34import crypto from "crypto"5import crc32 from "buffer-crc32"67import fs from "fs/promises"8import fetch from "node-fetch"910// Note: PayPal only delivers webhooks to port 443 (HTTPS).11// Development ports can be used in a forwarding configuration, set 443 if this is front-facing.12const { LISTEN_PORT = 8888, LISTEN_PATH="/", CACHE_DIR = ".", WEBHOOK_ID = "<from when the listener URL was subscribed>" } = process.env;1314async function downloadAndCache(url, cacheKey) {15 if(!cacheKey) {16 cacheKey = url.replace(/\W+/g, '-')17 }18 const filePath = `${CACHE_DIR}/${cacheKey}`;1920 // Check if cached file exists21 const cachedData = await fs.readFile(filePath, 'utf-8').catch(() => null);22 if (cachedData) {23 return cachedData;24 }2526 // Download the file if not cached27 const response = await fetch(url);28 const data = await response.text()29 await fs.writeFile(filePath, data);3031 return data;32}3334const app = express();3536app.post(LISTEN_PATH, express.raw({type: 'application/json'}), async (request, response) => {37 const headers = request.headers;38 const event = request.body;39 const data = JSON.parse(event)4041 console.log(`headers`, headers);42 console.log(`parsed json`, JSON.stringify(data, null, 2));43 console.log(`raw event: ${event}`);4445 const isSignatureValid = await verifySignature(event, headers);4647 if (isSignatureValid) {48 console.log('Signature is valid.');4950 // Successful receipt of webhook, do something with the webhook data here to process it, e.g. write to database51 console.log(`Received event`, JSON.stringify(data, null, 2));5253 } else {54 console.log(`Signature is not valid for ${data?.id} ${headers?.['correlation-id']}`);55 // Reject processing the webhook event. May wish to log all headers+data for debug purposes.56 }5758 // Return a 200 response to mark successful webhook delivery59 response.sendStatus(200);60});6162async function verifySignature(event, headers) {63 const transmissionId = headers['paypal-transmission-id']64 const timeStamp = headers['paypal-transmission-time']65 const crc = parseInt("0x" + crc32(event).toString('hex')); // hex crc32 of raw event data, parsed to decimal form6667 const message = `${transmissionId}|${timeStamp}|${WEBHOOK_ID}|${crc}`68 console.log(`Original signed message ${message}`);6970 const certPem = await downloadAndCache(headers['paypal-cert-url']);7172 // Create buffer from base64-encoded signature73 const signatureBuffer = Buffer.from(headers['paypal-transmission-sig'], 'base64');7475 // Create a verification object76 const verifier = crypto.createVerify('SHA256');7778 // Add the original message to the verifier79 verifier.update(message);8081 return verifier.verify(certPem, signatureBuffer);82}8384app.listen(LISTEN_PORT, () => {85 console.log(`Node server listening at http://localhost:${LISTEN_PORT}/`);86});
Postback method
Alternatively to the above, rather than verifying the signature yourself you can post the event back to the verify-webhook-signature REST API endpoint. Example:1curl -X POST https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature \2 -H "Content-Type: application/json" \3 -H "Authorization: Bearer ACCESS-TOKEN" \4 -d '{5 "transmission_id": "db49fb10-1343-11ef-ac58-e32457403f67",6 "transmission_time": "2024-05-16T05:19:23Z",7 "cert_url": "https://api.sandbox.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-ab66f33d",8 "auth_algo": "SHA256withRSA",9 "transmission_sig": "ab2tJk1VCFm4EqdSuKqezr38rTdY3JeRQlw94V1xYAIQVgLScAFn2XLRAhtyo/1jp8RZPO80hLpWfbdvvwyxK1u6L6NX/bnr0vA1wpfkwbqDT/Z01YP2VvJiYQdhpf/yXosoIKwP+37pYdAhwCsIu2D5fJAaDXpM8A06SpZJkR4f5+k566PDVPP7RCED9Xny4Yo0WyVaFORkiDJkBLGhUUIoO7Jv5JP5RQmFqVxhwSAaf4YYy/2r21r5HMQgAIgtS7oHYRea43Ze03Zz0EWqJu/hvKBzKHqhcvUPF64hVg8Nk2yqjnW7ZHgBWoHil2SJ1xQzaq2vDCXv5gWDn5+TZA==",10 "webhook_id": "0NH55953DH663215D",11 "webhook_event": {"id":"WH-3F562076HD293871E-75F399086E414290U","event_version":"1.0","create_time":"2024-05-16T05:19:19.355Z","resource_type":"capture","resource_version":"2.0","event_type":"PAYMENT.CAPTURE.COMPLETED","summary":"Payment completed for $ 500.0 USD","resource":{"payee":{"email_address":"receivingbusiness@example.com","merchant_id":"QDGTZ7B92B9QT"},"amount":{"value":"500.00","currency_code":"USD"},"seller_protection":{"dispute_categories":["ITEM_NOT_RECEIVED","UNAUTHORIZED_TRANSACTION"],"status":"ELIGIBLE"},"supplementary_data":{"related_ids":{"order_id":"9P99943869582473S"}},"update_time":"2024-05-16T05:19:15Z","create_time":"2024-05-16T05:19:15Z","final_capture":true,"seller_receivable_breakdown":{"paypal_fee":{"value":"25.44","currency_code":"USD"},"gross_amount":{"value":"500.00","currency_code":"USD"},"net_amount":{"value":"474.56","currency_code":"USD"}},"links":[{"method":"GET","rel":"self","href":"https://api.sandbox.paypal.com/v2/payments/captures/3Y662965014333303"},{"method":"POST","rel":"refund","href":"https://api.sandbox.paypal.com/v2/payments/captures/3Y662965014333303/refund"},{"method":"GET","rel":"up","href":"https://api.sandbox.paypal.com/v2/checkout/orders/9P99943869582473S"}],"id":"3Y662965014333303","status":"COMPLETED"},"links":[{"href":"https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-3F562076HD293871E-75F399086E414290U","rel":"self","method":"GET"},{"href":"https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-3F562076HD293871E-75F399086E414290U/resend","rel":"resend","method":"POST"}]}12}'
Note: It is essential that the webhook_event data be posted back exactly as it was received, with no deviations in formatting or content of any kind. Parsing to an array/object followed by re-serializing back to a JSON string can fail verification.
Test with sandbox events
Once a listener URL is able to receive mock events successfully, the next step is to subscribe it to a sandbox REST App's events in Application management and generate actual test events with an integration that uses the app (for example, make a subscription payment resulting in a PAYMENT.SALE.COMPLETED event). Then you can proceed with having the listener do something useful with the message data (for instance, write to a database).
Troubleshooting delivery problems
If PayPal cannot connect to a listener URL and receives no HTTP status code response, the most common reasons are if incoming HTTPS connections on port 443 are being blocked by a firewall or other rule, or if the domain webhooks are being sent to is marked as malware, phishing, or as involved in some other abuse by url filtering services. One place where you can check the status of a domain is: urlfiltering.paloaltonetworks.com. (If your domain is marked as abusive or having malware, take action to secure it and request recategorization there, or use a different domain to receive webhooks from PayPal)
If a status code is being returned but is non-2xx, such as a 404 or 500, this indicates a problem with the listener app/script or its webserver configuration and must be troubleshooted there.