diff --git a/README.md b/README.md index f515159..2fdcf4d 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,9 @@ npm install npm start ``` -Take note of the public URL provided by ngrok: it should be listed when the monitor starts. +A [Stripe webhook endpoint](https://stripe.com/docs/webhooks/setup) will be automatically provisioned and pointed at your ngrok tunnel, subscribed to all Stripe events. -**Don't want to use ngrok?** As long as Stripe can reach the webhooks endpoint via a public URL, you'll receive updates. - -### Subscribe to webhook notifications - -In your Stripe Dashboard, go to the _API_ section, then click on the _Webhooks_ tab. - -You should add a receiving endpoint by clicking _Add Endpoint_. Fill in the public URL provided by ngrok, or any other public URL that can reach the webhook monitor. - -![](https://raw.githubusercontent.com/stripe/stripe-webhook-monitor/master/screenshots/setting-up-webhooks.png) +**Don't want to use ngrok?** As long as Stripe can reach the webhooks endpoint via a public URL, you'll receive updates. Set the `webhookSigningSecret` configuration to ensure your events are signed correctly. ## Troubleshooting diff --git a/config.sample.js b/config.sample.js index ecdc1ce..8b24b02 100644 --- a/config.sample.js +++ b/config.sample.js @@ -4,7 +4,10 @@ module.exports = { port: 4000, stripe: { // Include your Stripe secret key here - secretKey: 'YOUR_STRIPE_SECRET_KEY' + secretKey: 'YOUR_STRIPE_SECRET_KEY', + // If not using ngrok, include your Webhook signing secret here + // i.e., whsec_somerandomcharacters + webhookSigningSecret: null }, /* Stripe needs a public URL for our server that it can ping with new events. diff --git a/package-lock.json b/package-lock.json index aa3664d..05a336f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,9 @@ "requires": true, "dependencies": { "@types/node": { - "version": "8.10.49", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.49.tgz", - "integrity": "sha512-YX30JVx0PvSmJ3Eqr74fYLGeBxD+C7vIL20ek+GGGLJeUbVYRUW3EzyAXpIRA0K8c8o0UWqR/GwEFYiFoz1T8w==" + "version": "8.10.51", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.51.tgz", + "integrity": "sha512-cArrlJp3Yv6IyFT/DYe+rlO8o3SIHraALbBW/+CcCYW/a9QucpLI+n2p4sRxAvl2O35TiecpX2heSZtJjvEO+Q==" }, "abbrev": { "version": "1.1.1", @@ -29,9 +29,9 @@ "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=" }, "ajv": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz", - "integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==", + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", "requires": { "fast-deep-equal": "^2.0.1", "fast-json-stable-stringify": "^2.0.0", @@ -1179,9 +1179,9 @@ "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" }, "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "is-arrayish": { "version": "0.2.1", @@ -1274,9 +1274,9 @@ } }, "js-yaml": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", - "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.10.0.tgz", + "integrity": "sha512-O2v52ffjLa9VeM43J4XocZE//WT9N0IiwDa3KSHH7Tu8CtH+1qM8SIZvnsTh6v+4yFy5KUY3BHUVwjpfAWsjIA==", "dev": true, "requires": { "argparse": "^1.0.7", @@ -1587,6 +1587,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, "log-symbols": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.1.0.tgz", @@ -1661,14 +1666,14 @@ "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, "ngrok": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/ngrok/-/ngrok-3.1.1.tgz", - "integrity": "sha512-dCW/Ni12GRBL7XIyiFmilKOfCW7UVFf65I/HpE8FC5rXGJwdhIYLc9Qr05GRb6hNs6fZGwyLpcDLnDhUSgZasQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/ngrok/-/ngrok-3.2.4.tgz", + "integrity": "sha512-+Ay8Gi4a9zipVNsNEomT/Q9gKw8QxBaq9q7fWokqMt0yxbmMrdmIvMMRGnKuZfafEZxnpHR+/CbS+dyNqdU5gQ==", "requires": { - "@types/node": "^8.10.30", + "@types/node": "^8.10.50", "decompress-zip": "^0.3.2", "request": "^2.88.0", - "request-promise-native": "^1.0.5", + "request-promise-native": "^1.0.7", "uuid": "^3.3.2" } }, @@ -1878,9 +1883,9 @@ "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" }, "psl": { - "version": "1.1.32", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.1.32.tgz", - "integrity": "sha512-MHACAkHpihU/REGGPLj4sEfc/XKW2bheigvHO1dUqjaKigMp1C8+WLQYRGgeKFMsw5PMfegZcaN8IDXK/cD0+g==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.2.0.tgz", + "integrity": "sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==" }, "punycode": { "version": "2.1.1", @@ -1893,9 +1898,9 @@ "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" }, "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, "readable-stream": { "version": "1.1.14", @@ -1949,6 +1954,13 @@ "tough-cookie": "~2.4.3", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" + }, + "dependencies": { + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + } } }, "request-promise-core": { @@ -1995,9 +2007,9 @@ } }, "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" }, "safer-buffer": { "version": "2.1.2", @@ -2191,36 +2203,14 @@ "dev": true }, "stripe": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-4.25.0.tgz", - "integrity": "sha512-sSRPSQ4BTSbdcevVSrtIJzlOCTIAXm8T38DE4zPL6ysYpIWGfIBdo2XnhouLK12/6cuLvaEInlfCZQgoEVzXpQ==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-7.5.0.tgz", + "integrity": "sha512-KiyHNqYrCVPHiDyJ+5IAUbxe7dlIw/vchvT1baOhouErGga9OdXRvbozN3gK7xlR87jvBBZ34wh3RX5D7yUOpw==", "requires": { - "bluebird": "^2.10.2", "lodash.isplainobject": "^4.0.6", - "object-assign": "^4.1.0", - "qs": "~6.0.4" - }, - "dependencies": { - "bluebird": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", - "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" - }, - "lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" - }, - "qs": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.0.4.tgz", - "integrity": "sha1-UQGdhHIMk5uCc36EVWp4Izjs6ns=" - } + "qs": "^6.6.0", + "safe-buffer": "^5.1.1", + "uuid": "^3.3.2" } }, "supports-color": { diff --git a/package.json b/package.json index c377126..42f0e7a 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "body-parser": "^1.17.2", "boxen": "^1.2.1", "express": "^4.15.3", - "ngrok": "^3.1.1", + "ngrok": "^3.2.4", "socket.io": "^2.2.0", - "stripe": "^4.23.1" + "stripe": "^7.5.0" } } diff --git a/screenshots/setting-up-webhooks.png b/screenshots/setting-up-webhooks.png deleted file mode 100644 index 2aa855a..0000000 Binary files a/screenshots/setting-up-webhooks.png and /dev/null differ diff --git a/server.js b/server.js index c2629a8..70a298c 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,6 @@ "use strict"; const config = require("./config.js"); -const boxen = require("boxen"); const stripe = require("stripe")(config.stripe.secretKey); const express = require("express"); const socketio = require("socket.io"); @@ -10,88 +9,126 @@ const bodyParser = require("body-parser"); const path = require("path"); const chalk = require("chalk"); -/* - We use two different Express servers for security reasons: our webhooks - endpoint needs to be publicly accessible, but we don't want our monitoring - dashboard to be publicly accessible since it may contain sensitive data. -*/ - -// The first Express server will serve Stripe Monitor (on a different port). -const monitor = express(); -const monitorServer = http.Server(monitor); -// We'll set up Socket.io to notify us of new events -const io = socketio(monitorServer); -let recentEvents = []; - -// Serve static files and start the server -monitor.use(express.static(path.join(__dirname, "public"))); -monitorServer.listen(config.port, () => { - console.log(`Stripe Monitor is up: http://localhost:${config.port}`); -}); - -// Provides environment details: the Dashboard URL will vary based on whether we're in test or live mode -monitor.get("/environment", async (req, res) => { +(async () => { + const webhooksPort = config.port + 1; + + let webhookSigningSecret, + ngrokUrl; + + // Set the Stripe dashboard URL and append in testmode let dashboardUrl = "https://dashboard.stripe.com/"; if (config.stripe.secretKey.startsWith("sk_test")) { dashboardUrl += "test/"; } - res.send({ dashboardUrl }); -}); - -// Provides the 20 most recent events (useful when the app first loads) -monitor.get("/recent-events", async (req, res) => { - let response = await stripe.events.list({ limit: 20 }); - recentEvents = response.data; - res.send(recentEvents); -}); - -// The second Express server will receive webhooks -const webhooks = express(); -const webhooksPort = config.port + 1; - -webhooks.use(bodyParser.json()); -webhooks.listen(webhooksPort, () => { - console.log(`Listening for webhooks: http://localhost:${webhooksPort}`); -}); - -// Provides an endpoint to receive webhooks -webhooks.post("/", async (req, res) => { - let event = req.body; - // Send a notification that we have a new event - // Here we're using Socket.io, but server-sent events or another mechanism can be used. - io.emit("event", event); - // Stripe needs to receive a 200 status from any webhooks endpoint - res.sendStatus(200); -}); - -// Use ngrok to provide a public URL for receiving webhooks -if (config.ngrok.enabled) { - const ngrok = require("ngrok"); - const boxen = require("boxen"); - - ngrok.connect( - { + + if (config.ngrok.enabled) { + const ngrok = require("ngrok"); + + // Provision ngrok tunnel + ngrokUrl = await ngrok.connect({ addr: webhooksPort, subdomain: config.ngrok.subdomain, authtoken: config.ngrok.authtoken - }, - function(err, url) { - if (err) { - console.log(err); - if (err.code === "ECONNREFUSED") { - console.log( - chalk.red(`Connection refused at ${err.address}:${err.port}`) - ); - process.exit(1); - } - console.log(chalk.yellow(`ngrok reported an error: ${err.msg}`)); + }).catch(error => { + console.log("Error starting ngrok tunnel", error); + process.exit(1); + }); + + // Provision Stripe webhook endpoint + const webhookEndpoint = await stripe.webhookEndpoints.create({ + url: ngrokUrl, + enabled_events: ['*'] + }).catch(error => { + console.log("Error provisioning webhook endpoint", error); + process.exit(1); + }); + + // Save webhook signing secret key + webhookSigningSecret = webhookEndpoint.secret; + + // Tear down Stripe webhook endpoint on CTRL+C + // (ngrok does this automatically) + process.on('SIGINT', async () => { + await stripe.webhookEndpoints.del(webhookEndpoint.id).catch(error => { + const webhookManagementUrl = `${dashboardUrl}/webhooks/${webhookEndpoint.id}`; + console.log(`Error deleting webhook endpoint, visit ${webhookManagementUrl}`, error); + process.exit(1); + }); + + process.exit(0); + }); + } else { + // Not using ngrok, but setting signing secret via config + if (config.stripe.webhookSigningSecret) { + webhookSigningSecret = config.stripe.webhookSigningSecret; + } + } + + /* + We use two different Express servers for security reasons: our webhooks + endpoint needs to be publicly accessible, but we don't want our monitoring + dashboard to be publicly accessible since it may contain sensitive data. + */ + + // The first Express server will serve Stripe Monitor (on a different port). + const monitor = express(); + const monitorServer = http.Server(monitor); + // We'll set up Socket.io to notify us of new events + const io = socketio(monitorServer); + let recentEvents = []; + + // Serve static files and start the server + monitor.use(express.static(path.join(__dirname, "public"))); + monitorServer.listen(config.port, () => { + console.log(`Stripe Monitor is up: http://localhost:${config.port}`); + }); + + // Provides environment details: the Dashboard URL will vary based on whether we're in test or live mode + monitor.get("/environment", async (req, res) => { + res.send({ dashboardUrl }); + }); + + // Provides the 20 most recent events (useful when the app first loads) + monitor.get("/recent-events", async (req, res) => { + let response = await stripe.events.list({ limit: 20 }); + recentEvents = response.data; + res.send(recentEvents); + }); + + // The second Express server will receive webhooks + const webhooks = express(); + + webhooks.listen(webhooksPort, () => { + const url = ngrokUrl ? ngrokUrl : `http://localhost:${webhooksPort}`; + console.log(`Listening for webhooks: ${url}`); + }); + + // Provides an endpoint to receive webhooks + webhooks.post("/", bodyParser.raw({ type: "application/json" }), async (req, res) => { + let event; + + // Check if signing secret has been set + if (webhookSigningSecret) { + const sig = req.headers['stripe-signature']; + + try { + event = stripe.webhooks.constructEvent(req.body, sig, webhookSigningSecret); + } catch (err) { console.log( - boxen(err.details.err.trim(), { - padding: { top: 0, right: 2, bottom: 0, left: 2 } - }) + chalk.red(`Failed to verify webhook signature: ${err.message}`) ); + res.sendStatus(400); + return; } - console.log(` └ Public URL for receiving Stripe webhooks: ${url}`); + } else { + // Signing secret is not set, just pass through + event = req.body; } - ); -} + + // Send a notification that we have a new event + // Here we're using Socket.io, but server-sent events or another mechanism can be used. + io.emit("event", event); + // Stripe needs to receive a 200 status from any webhooks endpoint + res.sendStatus(200); + }); +})();