LogoLogo
Connect
Connect
  • 🏡Welcome to LHV Connect API
  • 📺News and Updates
    • 📈Performance and Stats
    • 🔔Notice of Change
  • 🧭Quick Start Guide
  • 🎥Connect Fundamentals
    • Authentication and Certificates
    • Environments
    • Messaging Pattern
    • Service Provider model
    • Technical limitations
    • Encoding and Languages
    • Response Compression
    • Dates and Time Zones
    • Response Codes and Error handling
    • Onboarding
    • Live Proving
    • LHV UK and Estonia integrations
    • FAQ and Tips
  • 📖Service Catalogue
    • 💚Heartbeat
      • Heartbeat - GET
      • Heartbeat Advanced - GET
    • 📩Messages Services
      • Get next message
      • Get list of messages
      • Get list of messages V2
      • Get message by response ID
      • Count number of messages
      • Mark message as processed
      • Mark batch of messages as processed
      • Mark batch of messages as processed V2
      • Messages Metadata
      • Get compressed message for testing
    • 💰Account Information Services
      • Account Balance
      • Account Statement
      • Transaction Notification
        • Incoming Bacs Credit Notification
    • 💸Payment Initiation Services
      • Pain.001.001.09
      • Pain.002.001.10
      • Samples
      • Authentication methods
      • Payment Scheme Selection
      • Payment Return Initiation
      • Payments Service Idempotency
      • Payments Originating Overseas
      • Legacy documents
        • pain.001.001.03 format
    • ✅Confirmation of Payee Services
      • Confirmation of Payee - Requester
      • Confirmation of Payee - Responder
    • 😶‍🌫️VIBAN Services
      • VIBAN Open
      • VIBAN Bulk Open
      • VIBAN Modify
      • VIBAN Info
      • VIBAN Close
      • VIBAN Notification
    • 🔗Indirect Scheme Access
      • Agency Account Synchronization
      • RTF - Routing Table Files message
      • 🧾Payment Collection Services
        • 💷Bacs Direct Debit
          • Bacs Direct Debit Mandate Initiation Request
          • Bacs Direct Debit Mandate Initiation Response
          • Bacs Direct Debit Mandate Initiation Response Confirmation
          • Bacs Direct Debit Mandate Cancellation Request
          • Bacs Direct Debit Mandate Cancellation Response
          • Direct Debit Incoming Collection Notification Request
          • Direct Debit Collection Notification Response
          • Direct Debit Collection Notification Response Confirmation
          • Direct Debit Reversal Notification Request
          • Examples
    • 📨Webhooks
      • Webhook Format and Processing
      • Managing Webhook Configurations
      • Webhook Security
      • Webhook Metadata
      • Full Bodied Webhooks
  • 🗓️Reference
    • Glossary
    • Code Reference Tables
      • Balance Type Codes
      • Credit and Debit Transaction Codes
      • Payment Scheme Codes
      • Direct Debit Scheme Codes
      • Payment Reject Codes
      • Payment Return Codes
      • Bacs Direct Debit Mandate Reject Codes
      • Bacs Direct Debit Reject Codes
      • Bacs Direct Debit Reversal Reason
      • Bank Transaction Codes
      • Transaction Purpose Codes
      • Category Purpose Codes
      • Private Person Identification Codes
      • Organisation Identification Type Codes
      • Payment Priority Codes
      • Charges Bearer Codes
  • ☎️Support
    • Contact
Powered by GitBook
On this page
  • Managing Your Shared Secret
  • Verifying HMACs
  • Example verifyHmac Code
  • Example Payload and HMAC

Was this helpful?

  1. Service Catalogue
  2. Webhooks

Webhook Security

Your webhook endpoint will be accessible to the public internet, so it's important that you verify incoming webhooks to ensure they have actually come from LHV. Every incoming webhook will have an X-LHV-HMAC header, containing a hex-encoded SHA256 HMAC formed from the webhook body and your shared secret.

Managing Your Shared Secret

Every webhook configuration has a secret, shared between you and LHV, which is used for verifying webhook payloads. You can use a same one for different configurations, or have each one be different.

You can either set the secret yourself, or leave it absent and we will generate one automatically. To increase security, we will only show you the secret once - in the response from a create/edit request.

When you edit an existing webhook, you can overwrite the secret with a new one you choose, or leave it absent to generate a new secret (used with immediate effect). If you are editing other fields but want to keep the same secret, then you must provide it in the edit request body.

The secrets themselves are represented as strings, but HMACs work on raw bytes, so you will need to convert it to a byte array of UTF-8 characters (example code is shown later). You can use any valid character in your secret, but it's often wise to avoid special characters anyway.

Secrets should have high entropy to ensure security. For our automaticly-generated secrets, we use a 64-character alphabet ([a-zA-Z0-9_\-]) and generate a string of length 64. This works out to 384 bits of entropy (LENGTH * log2(ALPHABET_SIZE)).

If you're generating your own secret, remember to use a cryptographically secure PRNG. For Kotlin/Java/JVM, try SecureRandom.getInstanceStrong()

Verifying HMACs

To verify a webhook, you should generate the expected HMAC in the same way, and then confirm that it matches the value from the header. If the result is not an exact match (or header is missing), you should discard the webhook as illegitimate, and log this as an error.

For security, you should use a constant-time equals function (as shown in the code examples). If you compare hex strings rather than bytes, then do so case insensitively (e.g. "af12" == "AF12").

Here is rough pseudocode for how you should use the result of HMAC validation. Actual implementations of verifyHmac are given in the next section

@POSTHandler("/my-webhook-endpoint")
fun webhookHandler(incomingWebhook: HttpMessage) {
    val rawPayload: ByteArray = incomingWebhook.requestBodyAsBytes
    val hmacString = incomingWebhook.headers["X-LHV-HMAC"]
    
    if (hmacString != null && verifyHmac(rawPayload, MY_SECRET, hmacString)) {
        process(incomingWebhook)
    } else {
        log.error("Webhook with invalid HMAC received!")
        fail()
    }
}

Note that rawPayload must be the exact bytes of the HTTP request body. Whitespace trimming or reformatting will result in a failed HMAC comparison. If you can only extract the body as a string then you can still convert it to a UTF-8 byte array - the example code does this to turn secret into secretBytes, so you can copy that for the request body.

Make sure to always use the SHA256algorithm when processing HMACs.

Once you have verified the HMAC of the message, it's also worth making sure that the clientCode and clientCountry fields are correct, and that the eventTimestamp is within expected bounds.

Example verifyHmac Code

Kotlin

import java.security.MessageDigest;
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

// https://mvnrepository.com/artifact/commons-codec/commons-codec
import org.apache.commons.codec.binary.Hex

const val HMAC_ALGORITHM = "HmacSHA256"

private fun generateHmac(payload: ByteArray, secret: String): ByteArray {
    val secretBytes = secret.encodeToByteArray()
    val keySpec = SecretKeySpec(secretBytes, HMAC_ALGORITHM)
    val mac = Mac.getInstance(HMAC_ALGORITHM)
    mac.init(keySpec)
    return mac.doFinal(payload)
}

fun verifyHmac(payload: ByteArray, secret: String, receivedHmacHeader: String): Boolean {
    val receivedHmac = Hex.decodeHex(receivedHmacHeader)
    val expectedHmac = generateHmac(payload, secret)
    return MessageDigest.isEqual(receivedHmac, expectedHmac)
}

Java

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.MessageDigest;
import java.util.Arrays;

// https://mvnrepository.com/artifact/commons-codec/commons-codec
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;

// Class definition omitted

private static final String HMAC_ALGORITHM = "HmacSHA256";

private static byte[] generateHmac(byte[] payload, String secret)
        throws NoSuchAlgorithmException, InvalidKeyException {
    byte[] secretBytes = secret.getBytes(StandardCharsets.UTF_8)
    SecretKeySpec keySpec = new SecretKeySpec(secretBytes, HMAC_ALGORITHM);
    Mac mac = Mac.getInstance(HMAC_ALGORITHM);
    mac.init(keySpec);
    return mac.doFinal(payload);
}

public static boolean verifyHmac(byte[] payload, String secret, String receivedHmacHeader)
        throws NoSuchAlgorithmException, InvalidKeyException, DecoderException {
    byte[] receivedHmac = Hex.decodeHex(receivedHmacHeader);
    byte[] expectedHmac = generateHmac(payload, secret);
    return MessageDigest.isEqual(receivedHmac, expectedHmac);
}

Typescript

import { createHmac, timingSafeEqual } from 'crypto';

function generateHmac(payload: Buffer, secret: string): Buffer {
    const secretBytes = Buffer.from(secret, 'utf8');
    const hmac = createHmac('sha256', secretBytes);
    hmac.update(payload);
    return hmac.digest();
}

function verifyHmac(payload: Buffer, secret: string, receivedHmacHeader: string): boolean {
    const receivedHmac = Buffer.from(receivedHmacHeader, 'hex');
    const expectedHmac = generateHmac(payload, secret);
    return timingSafeEqual(receivedHmac, expectedHmac);
}

Javascript

const crypto = require('crypto');

function generateHmac(payload, secret) {
    const secretBytes = Buffer.from(secret, 'utf8');
    const hmac = crypto.createHmac('sha256', secretBytes);
    hmac.update(payload);
    return hmac.digest();
}

function verifyHmac(payload, secret, receivedHmacHeader) {
    const receivedHmac = Buffer.from(receivedHmacHeader, 'hex');
    const expectedHmac = generateHmac(payload, secret);
    return crypto.timingSafeEqual(receivedHmac, expectedHmac);
}
import hmac
import hashlib

def generate_hmac(payload: bytes, secret: str) -> bytes:
    secret_bytes = secret.encode('utf-8')
    return hmac.new(secret_bytes, payload, hashlib.sha256).digest()

def verify_hmac(payload: bytes, secret: str, received_hmac_header: str) -> bool:
    received_hmac = bytes.fromhex(received_hmac_header)
    expected_hmac = generate_hmac(payload, secret)
    return hmac.compare_digest(received_hmac, expected_hmac)
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func generateHmac(payload []byte, secret string) []byte {
    secretBytes := []byte(secret)
    h := hmac.New(sha256.New, secretBytes)
    h.Write(payload)
    return h.Sum(nil)
}

func verifyHmac(payload []byte, secret, receivedHmacHeader string) bool {
    receivedHmac, err := hex.DecodeString(receivedHmacHeader)
    if err != nil {
        return false
    }
    expectedHmac := generateHmac(payload, secret)
    return hmac.Equal(receivedHmac, expectedHmac)
}
require 'openssl'
require 'rack/utils' # https://rubygems.org/gems/rack

# For this implementation the payload and secret actually *should* just be strings
def generate_hmac(payload, secret)
  OpenSSL::HMAC.digest('sha256', secret, payload)
end

def verify_hmac(payload, secret, received_hmac_header)
  received_hmac = [received_hmac_header].pack('H*')
  expected_hmac = generate_hmac(payload, secret)
  Rack::Utils.secure_compare(received_hmac, expected_hmac)
end
using System;
using System.Text;
using System.Security.Cryptography;

// Class definition omitted

private const string HMAC_ALGORITHM = "HmacSHA256";

private static byte[] GenerateHmac(byte[] payload, string secret)
{
    byte[] secretBytes = System.Text.Encoding.UTF8.GetBytes(secret)
    using (var hmac = new HMACSHA256(secretBytes))
    {
        return hmac.ComputeHash(payload);
    }
}

public static bool VerifyHmac(byte[] payload, string secret, string receivedHmacHeader)
{
    byte[] receivedHmac = Convert.FromHexString(receivedHmacHeader);
    byte[] expectedHmac = GenerateHmac(payload, secret);
    return CryptographicOperations.FixedTimeEquals(receivedHmac, expectedHmac);
}

Example Payload and HMAC

You can use these values to test that you're generating HMACs correctly:

  • Secret: example_secret_for_docs

  • Payload: {"eventId":"bd960667-37cf-4698-b63a-919aa282ef3c","eventTimestamp":"2024-07-10T10:39:33.295567","subscriptionReference":"2345","messageResponseId":"0566defb-2c1b-4649-9c1d-cda27d2f5a10","messageRequestId":"dd14b64d-0553-4bea-acb5-71f45697e126","messageType":"VIBAN_OPEN","messageCreatedTime":"2024-07-10T10:39:33.263134","clientCode":"123","clientCountry":"GB","bankCode":"LHVUK"}

  • X-LHV-HMAC: 79ece3b561a9a95a56edf5d8c63224b1fa43f0198442537abe22a7e3ba99e774

Remember that you must copy these exactly. Even a stray space at the end will invalidate it.

Having trouble with the above example? Here are the same secret and payload encoded in base64. Try decoding and using them in your test instead.

If that solves the problem then it was probably just down to browser encoding or copy and paste issues, not your implementation.

  • Secret: ZXhhbXBsZV9zZWNyZXRfZm9yX2RvY3M=

  • Payload: eyJldmVudElkIjoiYmQ5NjA2NjctMzdjZi00Njk4LWI2M2EtOTE5YWEyODJlZjNjIiwiZXZlbnRUaW1lc3RhbXAiOiIyMDI0LTA3LTEwVDEwOjM5OjMzLjI5NTU2NyIsInN1YnNjcmlwdGlvblJlZmVyZW5jZSI6IjIzNDUiLCJtZXNzYWdlUmVzcG9uc2VJZCI6IjA1NjZkZWZiLTJjMWItNDY0OS05YzFkLWNkYTI3ZDJmNWExMCIsIm1lc3NhZ2VSZXF1ZXN0SWQiOiJkZDE0YjY0ZC0wNTUzLTRiZWEtYWNiNS03MWY0NTY5N2UxMjYiLCJtZXNzYWdlVHlwZSI6IlZJQkFOX09QRU4iLCJtZXNzYWdlQ3JlYXRlZFRpbWUiOiIyMDI0LTA3LTEwVDEwOjM5OjMzLjI2MzEzNCIsImNsaWVudENvZGUiOiIxMjMiLCJjbGllbnRDb3VudHJ5IjoiR0IiLCJiYW5rQ29kZSI6IkxIVlVLIn0=

PreviousManaging Webhook ConfigurationsNextWebhook Metadata

Last updated 9 months ago

Was this helpful?

📖
📨