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:
{
"payload": "<base64url-encoded notification JSON>",
"signatures": [
{
"protected": "<base64url-encoded JWS header>",
"signature": "<base64url-encoded signature>"
}
]
}| Field | Description |
|---|---|
payload | Base64url-encoded notification JSON. Decode this to read the event. |
signatures[0].protected | Base64url-encoded JWS protected header. Contains alg, iat, kid, typ, and url. |
signatures[0].signature | Base64url-encoded signature over the protected header and payload. |
Decoded Protected Header
{
"alg": "ES256",
"iat": 1773308486,
"kid": "SkP41XUw7hiTkAf6Zmbe4o",
"typ": "JWS",
"url": "http://localhost:8000"
}| Field | Description |
|---|---|
alg | Signing algorithm. Always ES256 (ECDSA with P-256 and SHA-256). |
iat | Unix timestamp (seconds) of when the event was issued. Use this to reject replayed requests. |
kid | Key ID of the Bahn signing key used. Use this to look up the correct public key. |
typ | Token type. Always JWS. |
url | The registered webhook URL this event was delivered to. |
How the Signature is Created
When Bahn dispatches a webhook:
- The notification JSON is serialized and base64url-encoded as the JWS
payload. - A protected header is built containing
alg,iat,kid,typ, and the targeturl. - The header and payload are signed using ES256 (ECDSA P-256) and Bahn's private key, producing a compact JWS (
header.payload.signature). - 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:
{
"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.
{
"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.
curl --location 'https://bn.cakedigibank.tech/api/public-keys'{
"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-kavcgzGLrtNSw5. Validate claims
After the signature passes, check:
urlin the protected header matches your registered webhook endpoint URL.iatis 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
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))
}