A command-line tool for generating and submitting Danish PBS (Pengeinstitutternes BetalingsSystemer) enrollment files to a bank's SFTP server.
PBS is the Danish bank payment system. This tool lets you:
- Generate a correctly formatted PBS enrollment file by entering customer data
- Verify the file meets the PBS format requirement (every line must be exactly 128 characters)
- Send the file to the bank via SFTP using SSH key authentication
- Archive old files and clean up temp files
- Python 3
paramiko— SFTP uploadpython-dotenv— loading credentials from.env
Install dependencies:
pip install -r requirements.txtPlace your private SSH key in the project folder and name it exactly:
id_rsa
No file extension — not id_rsa.pem or id_rsa.txt, just id_rsa.
Run the tool and choose option 5 (Setup) from the main menu, then option 1 (Configure settings). You will be prompted for:
| Variable | Description |
|---|---|
SFTP_HOST |
Bank SFTP server hostname |
SFTP_PORT |
Port (usually 22) |
SFTP_USERNAME |
Your SFTP username |
SFTP_PASSPHRASE |
Passphrase for the SSH key (if any) |
SFTP_REMOTE_DIR |
Remote folder to upload into (default /) |
CREDITOR_DATA_SUPPLIER |
Data supplier number (8 digits) |
CREDITOR_SUBSYSTEM |
Subsystem identifier (usually BS1) |
CREDITOR_PBS_NUMBER |
Your PBS / creditor number (8 digits) |
CREDITOR_DEBTOR_GROUP |
Debtor group number (1–5 digits) |
CREDITOR_NAME |
Creditor name in the file (max 15 characters) |
The creditor values go into fixed-width fields in the PBS file, so generate.py checks their exact widths and refuses to build a file until they are valid.
send.py refuses to connect to a server it does not recognise — that is what protects the upload from a man-in-the-middle attack. Before the first upload, fetch the bank's host key and save it to a known_hosts file in the project folder:
ssh-keyscan -p 22 sftp.bank.dk >> known_hostsThen verify the key's fingerprint with the bank before uploading. send.py prints these exact instructions if the host is unknown.
This creates a .env file in the project folder (see .env.example for the expected format). Do not commit .env or id_rsa to version control — both are already listed in .gitignore.
Start the tool:
python3 runme.pyYou will see the main menu:
=== Main Menu - PBS Enrollment ===
1. Create enrollment file
2. Verify the file
3. Send file to PBS
4. Reset and archive
5. Setup (SFTP + creditor settings)
6. Exit
Walks you through entering customer data:
- Customer number (3–5 digits)
- CPR number — validated against the full Danish CPR spec (date check, century table, modulus-11). Dashes and spaces are accepted.
- Registration number (4 digits)
- Account number (1–10 digits)
You can add multiple customers in one session. The output is saved as PBS-DDMMYY-HHMMSS.txt.
Checks every line in the generated PBS file is exactly 128 characters, as required by the PBS format.
Shows the newest PBS*.txt file and asks for confirmation, then uploads it to the bank's SFTP server using the settings from .env and the id_rsa key (Ed25519, ECDSA and RSA keys are all supported). The server must be listed in known_hosts (Setup step 3) — unknown servers are refused. Failures are reported with a non-zero exit code so the menu shows an error instead of "completed successfully".
Note: tested against a local SFTP server; not yet verified against the bank's production server.
Copies all PBS*.txt files to an archive/ subfolder, then deletes the archived PBS files from the working directory.
Opens a submenu to configure or view your SFTP and creditor settings, and shows the status of your SSH key.
| File | Purpose |
|---|---|
runme.py |
Entry point — main menu |
generate.py |
Builds the PBS enrollment file |
cpr_validator.py |
Danish CPR number validator |
verify.py |
Validates PBS file line lengths |
send.py |
Uploads the file via SFTP |
reset.py |
Archives and cleans up files |
setup.py |
SFTP + creditor configuration setup |
requirements.txt |
Python dependencies |
.env.example |
Template showing the settings .env needs |
CLAUDE.md |
Context notes for AI-assisted development |
docs/ |
Official Betalingsservice record-layout reference |
samples/ |
Example generated files (fictitious data only) |
id_rsa |
|
.env |
The CPR validator (cpr_validator.py) implements the official Danish CPR spec and reports one of three statuses:
| Status | Meaning |
|---|---|
✅ VALID |
Passes all checks including modulus-11 |
UNVERIFIABLE |
Structurally correct but fails modulus-11 — may be a legitimate post-2007 number |
❌ INVALID |
Definitively wrong (bad date, invalid century/sequence combo) |
The validator can also be run standalone:
python3 cpr_validator.py 0101901234
python3 cpr_validator.py # interactive modeGenerated PBS files contain CPR numbers (personal data) — treat them as sensitive. The repo's .gitignore blocks everything that must never be committed:
.env # SFTP credentials
id_rsa # SSH private key
*.txt # PBS files (CPR data) — requirements.txt is explicitly re-allowed
archive/ # archived PBS files
Don't weaken the *.txt rule: it's what keeps CPR data out of git. If a secret or a PBS file is ever committed, scrub the git history (git filter-repo) — deleting the file in a later commit is not enough.
- No automated tests yet.
cpr_validator.pyand the 128-char templates are good first candidates. send.pyhas not been verified against the bank's production SFTP server — only against a local test server.
(Resolved since the July 2026 audit: creditor values moved from hardcoded templates to .env; send.py now uploads the newest file with a confirmation prompt, verifies the bank's host key against known_hosts, supports Ed25519/ECDSA/RSA keys, and gives clear errors when settings are missing; verify.py and runme.py now report failures with real exit codes instead of "completed successfully".)