Skip to content

Webhook Signatures ​

Every webhook request from Bahn is delivered as a JWS JSON Serialization (Content-Type: application/jose+json). The signature lets you verify that the request was genuinely sent by Bahn and has not been tampered with.

Request Body Structure ​

The request body is a JWS object with the following shape:

json
{
  "payload": "<base64url-encoded notification JSON>",
  "signatures": [
    {
      "protected": "<base64url-encoded JWS header>",
      "signature": "<base64url-encoded signature>"
    }
  ]
}
FieldDescription
payloadBase64url-encoded notification JSON. Decode this to read the event.
signatures[0].protectedBase64url-encoded JWS protected header. Contains alg, iat, kid, typ, and url.
signatures[0].signatureBase64url-encoded signature over the protected header and payload.

Decoded Protected Header ​

json
{
  "alg": "ES256",
  "iat": 1773308486,
  "kid": "SkP41XUw7hiTkAf6Zmbe4o",
  "typ": "JWS",
  "url": "http://localhost:8000"
}
FieldDescription
algSigning algorithm. Always ES256 (ECDSA with P-256 and SHA-256).
iatUnix timestamp (seconds) of when the event was issued. Use this to reject replayed requests.
kidKey ID of the Bahn signing key used. Use this to look up the correct public key.
typToken type. Always JWS.
urlThe registered webhook URL this event was delivered to.

How the Signature is Created ​

When Bahn dispatches a webhook:

  1. The notification JSON is serialized and base64url-encoded as the JWS payload.
  2. A protected header is built containing alg, iat, kid, typ, and the target url.
  3. The header and payload are signed using ES256 (ECDSA P-256) and Bahn's private key, producing a compact JWS (header.payload.signature).
  4. The compact JWS is restructured into JWS JSON Serialization format and sent as the request body.

How to Verify ​

1. Parse the JWS body ​

Read the request body and parse the JWS JSON Serialization:

json
{
  "payload": "eyJtZXJjaGFudF9pZCI6ImNfdGlja2V0IiwiZXZlbnRfaWQiOiI3NDI1NDhlNy00YzUzLTQwMjAtYmMyOC01NjU2N2ExYWY2ZDUiLCJldmVudF9uYW1lIjoicGF5bWVudF9pbnRlbnQuc3RhdHVzLnN1Y2NlZWRlZCIsImRhdGFfdHlwZSI6InBheW1lbnRfaW50ZW50IiwiZGF0YSI6eyJpZCI6ImM2YWY4ODU1LWNiZjYtNDJhMC05YzQ0LTM2ZGUwZGUwYTc5MyIsInN0YXR1cyI6InN1Y2NlZWRlZCIsImFtb3VudCI6IjE0MDAwIiwiY3VycmVuY3kiOiI3MDQiLCJwYXltZW50X21ldGhvZF9pZCI6IjAxOWNmNjY4LThiODktN2UyMi1hNmUzLWI4ODllY2M1NGI4NyIsImxhdGVzdF9jaGFyZ2VfaWQiOiI0ZjViMWFkNC0xYmI3LTQ0NTItOTMwMS04YWExMTI0MTc0YjAiLCJtZXRhZGF0YSI6eyJrZXlfMCI6InN0cmluZyIsImtleV8xIjoic3RyaW5nIn0sImNyZWF0ZWRfYXQiOiIyMDI2LTAzLTE2VDExOjI5OjI4Ljc5MzUxODYwMloiLCJ1cGRhdGVkX2F0IjoiMjAyNi0wMy0xNlQxMToyOToyOC44MjAwNjNaIiwiY2FuY2VsZWRfYXQiOm51bGx9LCJwdWJsaXNoZWRfYXQiOiIyMDI2LTAzLTE2VDE4OjI5OjM2LjI4MzIxNzA2NyswNzowMCJ9",
  "signatures": [
    {
      "protected": "eyJhbGciOiJFUzI1NiIsImlhdCI6MTc3MzY2MDU3Niwia2lkIjoiU2tQNDFYVXc3aGlUa0FmNlptYmU0byIsInR5cCI6IkpXUyIsInVybCI6Imh0dHBzOi8vYm4uY2FrZWRpZ2liYW5rLnRlY2gvbWVyY2hhbnQtc2ltdWxhdGlvbi93ZWJob29rIn0",
      "signature": "HT-RvOtf0Y0cUoCCEBRMkBAa1Q00d2ZxWOsOo0E0mH47MCvOsBGe87aZr4fV-EhEsnOtZWRg-kavcgzGLrtNSw"
    }
  ]
}

2. Decode the protected header ​

Base64url-decode signatures[0].protected and parse the JSON. Extract kid, iat, and url.

json
{
  "alg": "ES256",
  "iat": 1773660576,
  "kid": "SkP41XUw7hiTkAf6Zmbe4o",
  "typ": "JWS",
  "url": "https://bn.cakedigibank.tech/merchant-simulation/webhook"
}

3. Fetch Bahn's public key ​

Call Bahn's [GetPublicKeys] endpoint and find the key whose kid matches the value from the header.

bash
curl --location 'https://bn.cakedigibank.tech/api/public-keys'
json
{
  "keys": [
    {
      "kty": "EC",
      "alg": "",
      "use": "sig",
      "kid": "SkP41XUw7hiTkAf6Zmbe4o",
      "n": "",
      "e": "",
      "x": "7HY5YbrgGN87Y-Nuifir8O1Nmkb0tflqo17jN5bvu5A",
      "y": "B_WVN6Ar4BHTWwvsfoUy3_KcHverFu69Ajby6bvEcEY",
      "crv": "P-256"
    }
  ]
}

4. Verify the signature ​

Reconstruct the compact JWS and verify it using the matched public key and ES256:

<signatures[0].protected>.<payload>.<signatures[0].signature>
eyJhbGciOiJFUzI1NiIsImlhdCI6MTc3MzY2MDU3Niwia2lkIjoiU2tQNDFYVXc3aGlUa0FmNlptYmU0byIsInR5cCI6IkpXUyIsInVybCI6Imh0dHBzOi8vYm4uY2FrZWRpZ2liYW5rLnRlY2gvbWVyY2hhbnQtc2ltdWxhdGlvbi93ZWJob29rIn0
.
eyJtZXJjaGFudF9pZCI6ImNfdGlja2V0IiwiZXZlbnRfaWQiOiI3NDI1NDhlNy00YzUzLTQwMjAtYmMyOC01NjU2N2ExYWY2ZDUiLCJldmVudF9uYW1lIjoicGF5bWVudF9pbnRlbnQuc3RhdHVzLnN1Y2NlZWRlZCIsImRhdGFfdHlwZSI6InBheW1lbnRfaW50ZW50IiwiZGF0YSI6eyJpZCI6ImM2YWY4ODU1LWNiZjYtNDJhMC05YzQ0LTM2ZGUwZGUwYTc5MyIsInN0YXR1cyI6InN1Y2NlZWRlZCIsImFtb3VudCI6IjE0MDAwIiwiY3VycmVuY3kiOiI3MDQiLCJwYXltZW50X21ldGhvZF9pZCI6IjAxOWNmNjY4LThiODktN2UyMi1hNmUzLWI4ODllY2M1NGI4NyIsImxhdGVzdF9jaGFyZ2VfaWQiOiI0ZjViMWFkNC0xYmI3LTQ0NTItOTMwMS04YWExMTI0MTc0YjAiLCJtZXRhZGF0YSI6eyJrZXlfMCI6InN0cmluZyIsImtleV8xIjoic3RyaW5nIn0sImNyZWF0ZWRfYXQiOiIyMDI2LTAzLTE2VDExOjI5OjI4Ljc5MzUxODYwMloiLCJ1cGRhdGVkX2F0IjoiMjAyNi0wMy0xNlQxMToyOToyOC44MjAwNjNaIiwiY2FuY2VsZWRfYXQiOm51bGx9LCJwdWJsaXNoZWRfYXQiOiIyMDI2LTAzLTE2VDE4OjI5OjM2LjI4MzIxNzA2NyswNzowMCJ9
.
HT-RvOtf0Y0cUoCCEBRMkBAa1Q00d2ZxWOsOo0E0mH47MCvOsBGe87aZr4fV-EhEsnOtZWRg-kavcgzGLrtNSw

5. Validate claims ​

After the signature passes, check:

  • url in the protected header matches your registered webhook endpoint URL.
  • iat is recent enough to reject replayed requests (e.g. reject if older than 5 minutes).

WARNING

Reject any request that fails signature verification, contains an unexpected url, or has an iat that is too old. Do not process the event payload until all checks pass.


Example ​

go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"

	"github.com/go-jose/go-jose/v4"
)

const publicKeyURL = "https://bn.cakedigibank.tech/api/public-keys"

var bahnKeys jose.JSONWebKeySet

func init() {
	resp, err := http.Get(publicKeyURL)
	if err != nil {
		log.Fatalf("get public keys: %v", err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatalf("read body: %v", err)
	}
	err = json.Unmarshal(body, &bahnKeys)
	if err != nil {
		log.Fatalf("unmarshal public keys: %v", err)
	}
}

func main() {
	payload := []byte(`{
		"payload": "eyJtZXJjaGFudF9pZCI6ImNfdGlja2V0IiwiZXZlbnRfaWQiOiI3NDI1NDhlNy00YzUzLTQwMjAtYmMyOC01NjU2N2ExYWY2ZDUiLCJldmVudF9uYW1lIjoicGF5bWVudF9pbnRlbnQuc3RhdHVzLnN1Y2NlZWRlZCIsImRhdGFfdHlwZSI6InBheW1lbnRfaW50ZW50IiwiZGF0YSI6eyJpZCI6ImM2YWY4ODU1LWNiZjYtNDJhMC05YzQ0LTM2ZGUwZGUwYTc5MyIsInN0YXR1cyI6InN1Y2NlZWRlZCIsImFtb3VudCI6IjE0MDAwIiwiY3VycmVuY3kiOiI3MDQiLCJwYXltZW50X21ldGhvZF9pZCI6IjAxOWNmNjY4LThiODktN2UyMi1hNmUzLWI4ODllY2M1NGI4NyIsImxhdGVzdF9jaGFyZ2VfaWQiOiI0ZjViMWFkNC0xYmI3LTQ0NTItOTMwMS04YWExMTI0MTc0YjAiLCJtZXRhZGF0YSI6eyJrZXlfMCI6InN0cmluZyIsImtleV8xIjoic3RyaW5nIn0sImNyZWF0ZWRfYXQiOiIyMDI2LTAzLTE2VDExOjI5OjI4Ljc5MzUxODYwMloiLCJ1cGRhdGVkX2F0IjoiMjAyNi0wMy0xNlQxMToyOToyOC44MjAwNjNaIiwiY2FuY2VsZWRfYXQiOm51bGx9LCJwdWJsaXNoZWRfYXQiOiIyMDI2LTAzLTE2VDE4OjI5OjM2LjI4MzIxNzA2NyswNzowMCJ9",
		"signatures": [
			{
			"protected": "eyJhbGciOiJFUzI1NiIsImlhdCI6MTc3MzY2MDU3Niwia2lkIjoiU2tQNDFYVXc3aGlUa0FmNlptYmU0byIsInR5cCI6IkpXUyIsInVybCI6Imh0dHBzOi8vYm4uY2FrZWRpZ2liYW5rLnRlY2gvbWVyY2hhbnQtc2ltdWxhdGlvbi93ZWJob29rIn0",
			"signature": "HT-RvOtf0Y0cUoCCEBRMkBAa1Q00d2ZxWOsOo0E0mH47MCvOsBGe87aZr4fV-EhEsnOtZWRg-kavcgzGLrtNSw"
			}
		]
	}`)

	jws, err := jose.ParseSigned(string(payload), []jose.SignatureAlgorithm{jose.ES256})
	if err != nil {
		log.Fatalf("parse signed: %v", err)
	}

	kid := jws.Signatures[0].Protected.KeyID
	verifyKey := bahnKeys.Key(kid)
	if len(verifyKey) == 0 {
		log.Fatalf("key %s not found", kid)
	}

	verifiedPayload, err := jws.Verify(verifyKey[0].Key)
	if err != nil {
		log.Fatalf("verify: %v", err)
	}
	fmt.Println(string(verifiedPayload))
}

Developers