Verifying the events

Webhooks carry a certain level of security risk, as malicious actors can attempt to send fake webhooks, posing as legitimate services. This can lead to security vulnerabilities and other application issues.

To ensure the authenticity of received events from Yoco, the following two actions are required:

  • Avoiding replay attacks
  • Validating the signature

Avoiding replay attacks

Inside the webhook header there is an additional field webhook-timestamp to indicate the Unix timestamp of when the webhook was signed. Use this timestamp to drastically reduce the chance of a replay attack.

Because the timestamp is included in the header, the same header cannot be sent with a different timestamp without causing an invalid signature. When verifying the signature, you should also validate that the timestamp is within an acceptable threshold from your current system time (in Unix epoch format).

We recommend a threshold of up to 3 minutes. If Yoco retries sending the webhook notification event, the timestamp at the time of sending the retried event is used, so each attempt would have a new timestamp and therefore a new hash.

Validating the signature

Events sent from our service contain the webhook-signature header. To ensure the webhook's authenticity and confirm its origin from our service, follow the provided steps to compare a generated signature with the signature from the header.

Steps

  1. Construct the signed content
  2. Determine the expected signature
  3. Compare signatures

1. Construct the signed content

To generate the signed content, concatenate the values of the webhook-id header, webhook-timestamp header, and the raw request body using a full stop character as a separator.

// Using Express
const headers = req.headers;
const requestBody = req.rawBody;
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signedContent = `${id}.${timestamp}.${requestBody}`;
caution

Make sure to use the raw request body in this step. Any minor modification to the body will generate a completely different signature.

2. Determine the expected signature

To calculate the expected signature, use the secret key and the signed content to generate a HMAC SHA256 signature. Then, encode this value using base64 encoding.

const crypto = require('crypto');
const secret = 'whsec_M0U0MDI3QjYzMEQ0NTK5NDNCIjVFMENCMDEzNzc1QkE=';
const secretBytes = new Buffer(secret.split('_')[1], "base64");
const expectedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
important

Before calculating the expected signature, ensure that you exclude the whsec_ prefix from the secret key.

3. Compare signatures

The webhook-signature header consists of a list of signatures and their version identifiers, separated by spaces. Typically, the signature list consists of only one element, but it can contain any number of signatures.

For example:

v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=
important

Before verifying the signature, make sure to remove the version prefix and delimiter (e.g., v1,).

The generated signature should be compared with the signature sent in the webhook-signature header. If the generated signature matches the provided signature, you can proceed with processing the webhook event.

caution

When comparing the signatures, it is recommended to use a constant-time string comparison method to prevent timing attacks.

Example

const express = require('express');
const crypto = require('crypto');
const getRawBody = require('raw-body');
const app = express();
app.use((req, res, next) => {
getRawBody(req, {
length: req.headers['content-length'],
encoding: 'utf-8'
}, (err, rawBody) => {
if (err) return next(err);
req.rawBody = rawBody;
next();
});
});
// Using Express
app.post("/my/webhook/url", function(req, res) {
const headers = req.headers;
const requestBody = req.rawBody;
// Construct the signed content
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signedContent = `${id}.${timestamp}.${requestBody}`;
// Determine the expected signature
const secret = 'whsec_M0U0MDI3QjYzMEQ0NTK5NDNCIjVFMENCMDEzNzc1QkE=';
const secretBytes = new Buffer(secret.split('_')[1], "base64");
const expectedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
// Compare the signatures
const signature = headers['webhook-signature'].split(' ')[0].split(',')[1]
if (crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature))) {
// process webhook event
return res.send(200);
}
// do not process webhook event
return res.send(403);
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});