Signing request is not hard, but it can be tricky if you don't understand the core concepts. That's why we provide example on how to implement this in Python. We'll do the following:
Get a API key
Get a Session, device and Installation on sandbox
Get the monetary account and add some money
Create a signature
Use the signature to create a payment.
Copy the files for , and
What are we trying to achieve
Eventually we make an API call to create a payment. This is because this is one of the calls that requires us to generate a signature.
What is a signature?
A signature is nothing more than a string of characters, but the exact string depends on what you use as input.
To give an example. Let's assume we want to sign the following payload:
{"hello":"World}
And now assume our signature for that payload is
Signature: fafd6dad81f90a2d6f7d60a635f206188a54039ba07a84757cf9daf24a60b57a
bunq's backend can verify that that signature indeed belongs to that payload. Based on the public key that we shared earlier.
How does this protect us?
If some attacker would send a different payload:
{"hello":"oops I changed the message"}
then that signature would change, and thus not be valid. A malicious attacker will also not be able to recreate a signature of his own, because they do not have our private_key.pem.
Getting started
we'll use the FastAPI framework. And will need FastApi, Cryptography and
We can install these by running: pip install cryptography fastapi uvicornWe'll have the following File structure
1 is adding the imports and writing a function that generates the RSA Key pair for us (The function in the example is set up to check if one is generated first, if it is it loads the existing one)
import os
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
import base64
import hashlib
# Function to generate RSA key pair
def generate_rsa_key_pair():
private_key_file = 'private_key.pem'
public_key_file = 'public_key.pem'
# Check if the key files exist
if os.path.exists(private_key_file) and os.path.exists(public_key_file):
# Read the existing keys from the text files
with open(private_key_file, 'r') as private_file:
private_key_pem = private_file.read()
with open(public_key_file, 'r') as public_file:
public_key_pem = public_file.read()
print("bunq - using existing keypair")
else:
# Generate new RSA keys with 2048 bits as required by Bunq
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
public_key = private_key.public_key()
# Serialize private key to PEM format (PKCS#8 as required by Bunq)
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
# Serialize public key to PEM format
public_key_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
# Save the keys to text files
with open(private_key_file, 'w') as private_file:
private_file.write(private_key_pem)
with open(public_key_file, 'w') as public_file:
public_file.write(public_key_pem)
print("bunq - creating new keypair [KEEP THESE FILES SAFE]")
return private_key_pem, public_key_pem
The most important function in the signing.py file is our sign_data() function that actually uses the generated keys and data we want to sign. Pay special attention to the specified hash and padding functions
"""
This is a continuation of the signing.py file we were writting
"""
def sign_data(data, private_key_pem):
"""Signs the given data with the provided private key using SHA256 and PKCS#1 v1.5 padding.
Args:
data (str): The data to sign (should be the JSON request body)
private_key_pem (str): The private key in PEM format
Returns:
str: Base64 encoded signature
"""
private_key = load_private_key(private_key_pem)
# Ensure the data is encoded in UTF-8 exactly as it will be sent
encoded_data = data.encode('utf-8')
# Debug: Print exact bytes being signed
print("\n[DEBUG] Signing Data Bytes:", encoded_data)
print("[DEBUG] SHA256 Hash of Data:", hashlib.sha256(encoded_data).hexdigest())
# Generate signature using SHA256 and PKCS#1 v1.5 padding as required by Bunq
signature = private_key.sign(
encoded_data,
padding.PKCS1v15(),
hashes.SHA256()
)
# Encode in Base64 (as required by Bunq API)
encoded_signature = base64.b64encode(signature).decode('utf-8')
# Debug: Print signature
print("[DEBUG] Base64 Encoded Signature:", encoded_signature)
return encoded_signature
We can call this function from our bunq_lib.py to generate a signature for each request we want to sign.
Ok now let's make this in bunq_lib.py
We start with the imports and creating a class, with variables for API keys, tokens and everything else we need. You can also see the 2 functions one to generate a device token, and one to load it if it already exists.
import json
import requests
from signing import generate_rsa_key_pair, sign_data, verify_response
import uuid
class BunqClient:
def __init__(self, api_key, service_name, base_url="https://public-api.sandbox.bunq.com/v1"):
self.service_name = service_name
self.api_key = api_key
self.private_key_pem, self.public_key_pem = generate_rsa_key_pair()
self.device_token = None
self.server_public_key = None
self.device_server_id = None
self.session_token = None
self.user_id = None
self.base_url = base_url
# Try to load device token from file
self.load_device_token()
def save_device_token(self):
"""Save the device token to a file."""
with open('device_token.json', 'w') as file:
json.dump({"device_token": self.device_token}, file)
def load_device_token(self):
"""Load the device token from a file if it exists."""
try:
with open('device_token.json', 'r') as file:
data = json.load(file)
self.device_token = data.get("device_token")
print(f"bunq - Loaded device token from file [KEEP THIS SAFE!]")
except FileNotFoundError:
print("bunq - No device token found, need to create a new one.")
Creating installation, registering the device and getting a session
In the same file as above we add a few more functions that allow us to create a installation, device and session
"""
This is a continuation of the bunq_lib.py file we were writting
"""
def create_installation(self):
if self.device_token is not None:
print("bunq - Device token already created.")
return
url = f"{self.base_url}/installation"
payload = json.dumps({"client_public_key": self.public_key_pem})
headers = {
'Content-Type': 'application/json',
'User-Agent': self.service_name,
'X-Bunq-Language': 'en_US',
'X-Bunq-Region': 'nl_NL',
'X-Bunq-Geolocation': '0 0 0 0 000',
}
response = requests.post(url, headers=headers, data=payload)
data = response.json()
self.device_token = next(item["Token"]["token"] for item in data["Response"] if "Token" in item)
self.server_public_key = next(item["ServerPublicKey"]["server_public_key"] for item in data["Response"] if "ServerPublicKey" in item)
self.save_device_token() # Save the token for future use
def create_device_server(self):
if not self.device_token:
print("bunq - Device token is required to create device server.")
return
url = f"{self.base_url}/device-server"
payload = json.dumps({
"description": self.service_name,
"secret": self.api_key,
"permitted_ips": ["*"]
})
signed_payload_signature = sign_data(payload, self.private_key_pem)
headers = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
'User-Agent': self.service_name,
'X-Bunq-Language': 'en_US',
'X-Bunq-Region': 'nl_NL',
'X-Bunq-Geolocation': '0 0 0 0 000',
'X-Bunq-Client-Authentication': self.device_token,
'X-Bunq-Client-Signature': signed_payload_signature
}
response = requests.post(url, headers=headers, data=payload)
self.device_server_id = response.text
def create_session(self):
if not self.device_token:
print("bunq - Device token is required to create session.")
return
url = f"{self.base_url}/session-server"
payload_dict = {"secret": self.api_key}
payload_json = json.dumps(payload_dict, separators=(',', ':'))
signed_payload_signature = sign_data(payload_json, self.private_key_pem)
headers = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
'User-Agent': self.service_name,
'X-Bunq-Language': 'en_US',
'X-Bunq-Region': 'nl_NL',
'X-Bunq-Geolocation': '0 0 0 0 000',
'X-Bunq-Client-Authentication': self.device_token,
'X-Bunq-Client-Signature': signed_payload_signature
}
response = requests.post(url, headers=headers, data=payload_json)
data = response.json()
print(data)
# Extract and save session token
self.session_token = next(item["Token"]["token"] for item in data["Response"] if "Token" in item)
self.user_id = next(item["UserPerson"]["id"] for item in data["Response"] if "UserPerson" in item)
print(f"bunq - Session Token: {self.session_token}")
print(f"bunq - User ID: {self.user_id}")
At this point your you could run the fastapi server with the command
uvicorn main:app --reload
That will start the server and will register your device and create the private and public keys. Your file tree should now look like this
Be aware that both the device_token.json as the private_key.pem contain credentials that should not be commited to source control or be shared in general. We recommend adding them to your environment variables. We created them as files here to be transparent on what the content looks like
Given that we now have a session and can make calls we can finish the rest of our file.
Let's make a payment and a request
In the rest of the file you can see 2 methods
1. request. This is just a generic function that can create a API call to a endpoint of your choosing.
The second function is to create a payment. This one is a bit more tricky as it requires us to sign.
"""
This is a continuation of the bunq_lib.py file we were writting
"""
def request(self, endpoint: str, method: str = "GET", data: dict = None):
url = f"{self.base_url}/user/{self.user_id}/{endpoint}"
print(f"[DEBUG] bunq - Requesting: {method} {url}")
# Default headers
headers = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
'User-Agent': self.service_name,
'X-Bunq-Language': 'en_US',
'X-Bunq-Region': 'nl_NL',
'X-Bunq-Geolocation': '0 0 0 0 000',
'X-Bunq-Client-Authentication': self.session_token,
'X-Bunq-Client-Request-Id': str(uuid.uuid4()) # Should be unique for each request
}
payload = None
if data and method == "POST":
# Ensure consistent JSON formatting by using separators
payload = json.dumps(data, separators=(',', ':'))
signed_payload_signature = sign_data(payload, self.private_key_pem)
headers["X-Bunq-Client-Signature"] = signed_payload_signature
print(f"[DEBUG] Request Payload: {payload}")
print(f"[DEBUG] Signed Payload Signature: {signed_payload_signature}")
try:
response = requests.request(method, url, headers=headers, data=payload)
print(f"[DEBUG] Response Status Code: {response.status_code}")
if response.status_code == 401:
print("[WARNING] Unauthorized (401) - Refreshing session...")
self.refresh_session()
response = requests.request(method, url, headers=headers, data=payload)
print(f"[DEBUG] Retried Response Status Code: {response.status_code}")
if response.status_code == 200:
response_body = response.text
server_signature = response.headers.get('X-Bunq-Server-Signature')
if server_signature and self.server_public_key:
# Verify the response signature
if not verify_response(response_body, server_signature, self.server_public_key):
raise Exception("Response signature verification failed")
print("[DEBUG] Response signature verified successfully")
return response.json()
print(f"[ERROR] Request failed: {response.status_code} - {response.text}")
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f"[ERROR] Request error: {e}")
raise
def create_payment(self, amount: str, recipient_iban: str, currency: str, from_monetary_account_id: str, description: str):
url = f"{self.base_url}/user/{self.user_id}/monetary-account/{from_monetary_account_id}/payment"
payload = json.dumps({
"amount": {
"value": str(amount),
"currency": str(currency)
},
"counterparty_alias": {
"type": "EMAIL",
"value": "sugardaddy@bunq.com",
"name": "Sugar Daddy"
},
"description": str(description)
}, separators=(',', ':')) # Ensure consistent JSON formatting
signature = sign_data(payload, self.private_key_pem)
headers = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
'User-Agent': self.service_name,
'X-Bunq-Language': 'en_US',
'X-Bunq-Region': 'nl_NL',
'X-Bunq-Client-Request-Id': str(uuid.uuid4()),
'X-Bunq-Geolocation': '0 0 0 0 000',
'X-Bunq-Client-Authentication': self.session_token,
'X-Bunq-Client-Signature': signature
}
response = requests.post(url, headers=headers, data=payload)
if response.status_code == 200:
response_body = response.text
server_signature = response.headers.get('X-Bunq-Server-Signature')
if server_signature and self.server_public_key:
# Verify the response signature
if not verify_response(response_body, server_signature, self.server_public_key):
raise Exception("Response signature verification failed")
print("[DEBUG] Response signature verified successfully")
return response.json()
Making a generic API call
In our main.py file we started a fastAPI server which has 2 endpoints
1. /get_cards - Which returns the cards
2. /monetary-account which returns the monetary accounts of the user.
You can see they both use the .request() method that we defined in the bunq_lib.py. They just call a different endpoint.
This calls the create_payment() function from the bunq_client class in our bunq_lib.py file. It sends along some payment details, like the receiver IBAN, the monetary account to pay from, a description and an amount.
We first create a payload. We have to be very specific in the exact formatting of the payload. That's why we specify the separators. We do this to ensure the payload that we sign and the payload that we send with the request match exactly. Else the signature will fail.
Now we call the sign_data() function from our signing.py file this will return a signature based on our private key and the payload.
We add that signature to our 'X-Bunq-Client-Signature'.
From there it is exactly like any API call where you wait for a 200 response status
We included 1 final step which is to also validate the 'X-Bunq-Server-Signature' signature. by calling the verify_response() function and comparing the signature bunq returned to the public key we stored in the installation step.
get the full files
If we go to our browser and type:
The set up is the same we simply navigate to:
to trigger the call to the endpoint