Verifying the events

Overview

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 undesired 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

The webhook header contains a webhook-timestamp property that describes a Unix timestamp indicating when the webhook was signed.

This timestamp should be used to reduce the chance of replay attacks.

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.

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 (.) as a separator.

1// Using Express
2
3const headers = req.headers;
4const requestBody = req.rawBody;
5
6const id = headers['webhook-id'];
7const timestamp = headers['webhook-timestamp'];
8
9const signedContent = `${id}.${timestamp}.${requestBody}`;

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.

1const crypto = require('crypto');
2
3const secret = 'whsec_XXXXXXXXXXXXXXXXXXXXXXXXX=';
4const secretBytes = Buffer.from(secret.split('_')[1], 'base64');
5
6const expectedSignature = crypto
7 .createHmac('sha256', secretBytes)
8 .update(signedContent)
9 .digest('base64');

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=

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.

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

Full example

1const express = require('express');
2const crypto = require('crypto');
3const getRawBody = require('raw-body');
4
5const app = express();
6
7app.use((req, res, next) => {
8 getRawBody(req, {
9 length: req.headers['content-length'],
10 encoding: 'utf-8'
11 }, (err, rawBody) => {
12 if (err) return next(err);
13 req.rawBody = rawBody;
14 next();
15 });
16});
17
18// Using Express
19app.post("/my/webhook/url", function(req, res) {
20 const headers = req.headers;
21 const requestBody = req.rawBody;
22
23 // Construct the signed content
24 const id = headers['webhook-id'];
25 const timestamp = headers['webhook-timestamp'];
26
27 const signedContent = `${id}.${timestamp}.${requestBody}`;
28
29 // Determine the expected signature
30 const secret = 'whsec_XXXXXXXXXXXXXXXXXXXXXXXXX=';
31 const secretBytes = Buffer.from(secret.split('_')[1], 'base64');
32
33 const expectedSignature = crypto
34 .createHmac('sha256', secretBytes)
35 .update(signedContent)
36 .digest('base64');
37
38 // Compare the signatures
39 const signature = headers['webhook-signature'].split(' ')[0].split(',')[1]
40 if (crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature))) {
41 // process webhook event
42 return res.send(200);
43 }
44 // do not process webhook event
45 return res.send(403);
46});
47
48app.listen(3000, () => {
49 console.log('Server is running on port 3000');
50});