Webhooks subscriptions

When you have created a subscription to a webhook event, and that event occurs, you will receive a webhook notification. A signed notification will contain a signature that you can use to verify that Vonage sent the notification and has not been tampered with. This guide describes how you should verify a signed webhook notification. You can verify the notification in two parts:

  • Verify the signature
  • Verify the payload
In this page

The webhook notification you receive includes the following:

  • A payload. The payload is the request body in the notification and uses the CloudEvent specification. 
  • A signature (in a JSON Web Token (JWT)). A signed webhook notification will contain a signature. The signature is generated from the following values:
    • The payload hash. The payload is hashed using the HMAC with SHA-256 (HS-256) algorithm.
    • A base64-encoded subscription secret. The subscription secret was provided or generated when the webhook subscription was created.
    The encoded secret is used to sign the payload hash to generate the signature.

The following simplified values are used throughout the guide. In reality, the values will be longer and more complex.

  • Subscription secret: my_secret_key
  • Base64 encoded secret: bXlfc2VjcmV0X2tleQ==
  • Payload: request_body
  • Hashed payload: #_request_body_#
  • Signature: a1b2c3d4

Verifying the signature in the webhook notification

For a complete example of how to consume and validate VCC webhooks, see NodeJs with ExpressJs consumer example.

When you receive a signed webhook notification, the notification contains the signature in a JWT stored in the Vonage-Signature header. You should verify the signature to ensure the notification originated from Vonage.

Expiring signatures

The signature in the webhook notification expires 5 minutes after it is issued. Verifying an expired JWT will result in verification failure. Ensure that wherever you are verifying the webhook notification has the correct time set; it is recommended that you use the Network Time Protocol (NTP).

You can use various libraries to verify JWTs. The following example uses jsonwebtoken to decode and verify the JWT (signature):

const { verify } = require('jsonwebtoken')
 
const token = ... // Extracted token from Vonage-Signature header
const secretBytes = Buffer.from(process.env.SECRET, 'base64')
try {
    const decodedToken = verify(token, secretBytes)
}
catch (error) {
    // Validation failed
}

If verification succeeds, the webhook notification was signed by Vonage.

Example values

ParameterValue
tokena1b2c3d4
process.env.SECRETmy_secret_key
secretBytesbXlfc2VjcmV0X2tleQ==

Verifying the payload

When you have verified the signature in the JWT, you can then verify the payload to ensure no tampering has occurred. To do so, you must compare the SHA-256 hash with the hashed payload (in the payload_hash field) in the JWT. If they do not match, the payload has been tampered with and the notification should be rejected.

You must verify the payload using the raw request body. Frameworks will often parse the request body into a JSON object, making it easier to work with. Converting this JSON object back into a string might result in a different value from the original, which would cause verification to fail.

The payload_hash field uses a hex digest, meaning the hash is represented using hexadecimal encoding. Depending on how you compare the values, you must either:

  • Use a hex digest when creating a hash of the payload (and then compare the strings)
  • Decode the payload_hash using hex encoding (and then compare the bytes)

Cryptographic comparison functions typically compare bytes and provide additional security benefits over a standard equivalence check. The following example uses node's crypto library to compare the values:

const { createHash, timingSafeEqual } = require('crypto');
 
const tokenPayloadHash = ... // extracted from decoded JWT
const payload = ... // raw body as bytes
 
const payloadHashBytes = createHash('sha256')
    .update(payload)
    .digest()
const expectedHashBytes = Buffer.from(tokenPayloadHash, 'hex')
 
// timingSafeEqual throws if different lengths
if (timingSafeEqual(payloadHashBytes, expectedHashBytes)) {
    console.log('Payload verified!')
} else {
    console.error('Payload mismatch!')
}

Example values

Parameter???Value???
tokenPayloadHash#_request_body_#
payloadrequest_body
payloadHashBytes#_request_body_# (in bytes)
expectedHashBytes #_request_body_# (in bytes)

Responding to the webhook notification

When you have validated/verified the notification, your server must respond with a success status code — a status code between 200 OK and 205 Reset Content).

Troubleshooting

Request body not parsing as JSON

VCC webhook notifications include a CloudEvent payload and so Content-Type is set to application/cloudevents+json. Web frameworks often parse the request body according to the request Content-Type and some don't recognize application/cloudevents+json. This results in the framework not parsing the payload as JSON. 

One example of a framework that doesn't recognize the content type is the express function, express.json(). In this case, you must include the content type as a parameter to the function:

express.json({ type: ['application/cloudevents+json'] })

JWT validation fails when using the correct secret

Secret not base64 decoded

Some libraries provide methods to validate a JWT using a string representation of the secret. Such methods will typically perform the validation using a UTF8 decoding of the secret. VCC webhooks subscription secrets are base64 encodings of a binary secret. Treating the subscription secret as plaintext – that is, UTF8 encoding – when validating the JWT will fail. This is because it results in a different binary value from that which was used to sign the JWT. 

There are various libraries available to handle this decoding for you. For example:

Secret not big enough

The algorithm used to sign VCC webhook notifications is HS256 (HMAC with SHA256). As described in https://tools.ietf.org/html/rfc7518#section-3.2, the length for this algorithm should be at least 256 bits (32 bytes). Some libraries refuse to validate a JWT with a secret smaller than this and may fail in ways that are not always obvious.

Payload hash comparison fails with a valid JWT

The web framework you are using might be parsing the request body into a JSON object. While this is useful for your application code to handle the event, converting this JSON object back into a string or byte array might result in a different value from the original. For example:

λ node

> const original = '{ "a" : 123 }'

> const parsed = JSON.parse(original)
{ a: 123 }

> const stringified = JSON.stringify(parsed)
'{"a":123}'

> stringified === original
false

You should therefore validate the request using the raw request body before it is manipulated by your framework. 

You will likely need access to both the raw and parsed request body to validate and process events. How this can be achieved will vary depending on the framework, but here is an example of how to achieve it in ExpressJs:

// req.Body will contain parsed JSON object, referenced by application code
// req.rawBody will contain the original bytes, referenced by signature validation
express.json({
    verify: (req, res, buf) => {
        req.rawBody = buf
    }
})
Support and documentation feedback

For general assistance, please contact Customer Support.

For help using this documentation, please send an email to docs_feedback@vonage.com. We're happy to hear from you. Your contribution helps everyone at Vonage! Please include the name of the page in your email.