Webhooks have become a popular way for different pieces of software to communicate with each other and are now an industry standard. As events drive most of the web, this concept has become more relevant because, unlike an API that awaits requests, webhooks actively send data and alerts whenever triggered.
Using webhooks speeds up integration. Rather than waiting on requests from your clients, you can send data about live events as they happen. Your clients don’t need to poll you or set up third-party listening services. Instead, they simply provide an endpoint to call when you have something for them.
For example, webhooks can trigger a Slackbot when a service onboards a new user. Webhooks can also send a text message to a DevOps team when the service detects an outage. This event-driven approach to API development enables you to respond to mission-critical events and reduce server load.
How can you make sure that your webhooks are secure and efficient? Let’s review some of the best practices you should consider when creating your own webhooks.
Verify Endpoints
You want to reassure your clients that they are receiving data from you and not bad actors. So, clients provide an endpoint to which you can POST and get a timely response status of 200. You should encourage them to verify the event you are sending before processing the data.
app.post("/service/webhook", async (req, res) => {
// Verify event
// Store event
// Send 200 response
res.status(200).send()
// Do some work
});
An easy way to provide your clients with some level of security is to instead use an API key for validation. The two applications can use a shared API key to verify the message has not been intercepted and changed in a man-in-the-middle attack. Your application can use the key to create a cryptographic summary of the webhook’s payload and return that value in a header. The client’s application then computes the value, checks if the two values match, and if so, confirms the data is valid.
For more critical webhooks, like those dealing with billing, you can provide a signature validation to ensure data integrity. Setting this up involves creating a shared secret between you and the client. You use it to add a header to your request.
import crypto from "crypto"
async function generateAndAddSignatureHeader({body, options, client}) {
// Some secure function get secret for this client.
const clientSecret = await getClientSecret(client);
const signature = crypto.createHmac('sha256', clientSecret).update(body).digest('base64')
options.headers["X-Webhook-Signature"] = signature
return {body, options}
}
Then, the client uses a piece of middleware to handle this.
import crypto from "crypto"
function validateSignature(req, res, next) {
// Get the reader from the incoming webhook
const sigHeader = req.headers["X-Webhook-Signature"]
const signature = crypto.createHmac('sha256', process.env.SHARED_SECRET).update(req.body).digest('base64')
if (signature !== sigHeader) {
// Webhook hasn't been signed properly.
res.status(401).send({ message: "Webhook is not properly signed"})
}
next()
}
Ensuring that webhooks have properly signed incoming events allows clients to be more confident of the integrity of the data received. This confidence can inspire them to trust this event layer and develop even more exciting features with your product.
Perform Error Handling
You live globally connected, but sometimes those connections don’t work. Even services with a billion users can go offline. You want to ensure your webhooks don’t make it more challenging for clients to recover.
What should you do when your webhook doesn’t reach its destination? First, you can ensure the event isn’t lost and that you have stored it in a queue so you can retry.
While retrying the events in the queue, you can follow a back-off strategy. The most common one is exponentially backing off. This stops your webhooks from causing an unintentional denial of service to your clients. In JavaScript, a non-queue-based system might look like this:
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
async function callWithRetry(fn, depth = 0) {
try {
return await fn()
} catch(error) {
if (depth > 7) {
// Flag and log
throw error
}
await wait(2 ** depth * 10)
return callWithRetry(fn, depth + 1)
}
}