#!/bin/sh # Register the /financials mutation callback with bunq. # # RUN THIS FROM THE MACHINE THE KEY BELONGS TO — your own, not the server. # That is the whole point: a bunq API key can initiate payments and has no # read-only scope, so it never goes on the internet-facing box. This script # uses it once, here, to tell bunq "push mutations to that URL". Afterwards # bunq talks to the server and the key stays home. # # tools/bunq-callback.sh list # accounts + current filters # tools/bunq-callback.sh set # install the filter # # The key comes from BUNQ_KEY in the repo-root .env (gitignored). Session # state — the RSA keypair, the installation token, whether the device was # registered — persists in ./bunq-state.json (gitignored, 0600), so re-runs # reuse the registration instead of making a new one every time. # # PERMITTED IPS: the device registration binds the key to the addresses that # may USE it. This defaults to this machine's current public address, NOT the # server's — bunq delivers callbacks outbound to an HTTPS URL, which has # nothing to do with this list, so whitelisting the server would grant # bank-API access to the box most exposed to attack and buy nothing. Override # with BUNQ_PERMITTED_IP if your address has moved. # # Note your home address is probably dynamic: when the ISP rotates it, API # calls from here start failing with a permission error. Re-running is not # enough — a device registration is per-key and cannot be re-pointed — so the # recovery is to add the new address to the existing device via the bunq app, # or to accept "*" and rely on the key secret alone. The callback itself keeps # working throughout; only your ability to run this script from here breaks. set -eu API_HOST="${BUNQ_API_HOST:-api.bunq.com}" STATE="${BUNQ_STATE:-bunq-state.json}" UA='catcrafts.net-tools/1.0 (+https://catcrafts.net)' [ -f .env ] || { echo "bunq: no .env in $(pwd) — run from the repo root" >&2; exit 1; } # shellcheck disable=SC1091 BUNQ_KEY=$(sed -n 's/^BUNQ_KEY=//p' .env | head -n1 | tr -d '"'"'"'') [ -n "$BUNQ_KEY" ] || { echo "bunq: BUNQ_KEY is empty in .env" >&2; exit 1; } for bin in openssl curl python3; do command -v "$bin" >/dev/null || { echo "bunq: $bin is required" >&2; exit 1; } done # ── state ───────────────────────────────────────────────────────────── # One JSON file, 0600: it holds a private key. state_get() { [ -f "$STATE" ] || { echo ""; return; } python3 -c 'import json,sys try: print(json.load(open(sys.argv[1])).get(sys.argv[2],"") or "") except Exception: print("")' "$STATE" "$1" } state_set() { python3 -c 'import json,os,sys p=sys.argv[1] try: d=json.load(open(p)) except Exception: d={} d[sys.argv[2]]=sys.argv[3] fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600) with os.fdopen(fd,"w") as f: json.dump(d,f)' "$STATE" "$1" "$2" chmod 600 "$STATE" } json_str() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; } json_get() { # json_get python3 -c 'import json,sys def walk(o,k): if isinstance(o,dict): if k in o: yield o[k] for v in o.values(): yield from walk(v,k) elif isinstance(o,list): for v in o: yield from walk(v,k) try: d=json.load(sys.stdin) except Exception: sys.exit(1) for hit in walk(d,sys.argv[1]): print(hit if not isinstance(hit,(dict,list)) else json.dumps(hit)); break' "$1" } # ── one signed API call ─────────────────────────────────────────────── # bunq stopped REQUIRING body signatures in 2019, but signing costs nothing # and a signed request is valid whether or not the server checks. api() { # api _m="$1"; _p="$2"; _b="$3"; _t="${4:-}" set -- -sS -X "$_m" \ -H "user-agent: $UA" -H 'cache-control: no-cache' \ -H "x-bunq-client-request-id: $(openssl rand -hex 8)" \ -H 'x-bunq-geolocation: 0 0 0 0 000' \ -H 'x-bunq-language: en_US' -H 'x-bunq-region: nl_NL' if [ -n "$_b" ]; then _sig=$(printf '%s' "$_b" | openssl dgst -sha256 -sign "$KEYFILE" | openssl base64 -A) set -- "$@" -H 'content-type: application/json' \ -H "x-bunq-client-signature: $_sig" --data-binary "$_b" fi [ -n "$_t" ] && set -- "$@" -H "x-bunq-client-authentication: $_t" curl "$@" "https://$API_HOST$_p" } die_on_error() { # reads a response, prints it and exits if it carries an Error _r="$1" if printf '%s' "$_r" | grep -q '"Error"'; then echo "bunq refused the call:" >&2 printf '%s\n' "$_r" | python3 -m json.tool >&2 2>/dev/null || printf '%s\n' "$_r" >&2 exit 1 fi } # ── handshake: installation -> device-server -> session ─────────────── KEYFILE="${BUNQ_KEYFILE:-bunq-client-key.pem}" if [ ! -f "$KEYFILE" ]; then echo "bunq: generating a client keypair -> $KEYFILE" openssl genrsa -out "$KEYFILE" 2048 2>/dev/null chmod 600 "$KEYFILE" fi INSTALL_TOKEN=$(state_get installation_token) if [ -z "$INSTALL_TOKEN" ]; then echo "bunq: registering the installation (once, ever)" _pub=$(openssl rsa -in "$KEYFILE" -pubout 2>/dev/null | json_str) _resp=$(api POST /v1/installation "{\"client_public_key\":$_pub}" "") die_on_error "$_resp" INSTALL_TOKEN=$(printf '%s' "$_resp" | json_get token) [ -n "$INSTALL_TOKEN" ] || { echo "bunq: no installation token in the response" >&2; exit 1; } state_set installation_token "$INSTALL_TOKEN" # bunq's own public key arrives here. Keep it: it is what # BUNQ_CALLBACK_PUBKEY on the server verifies callback signatures against. printf '%s' "$_resp" | json_get server_public_key > bunq-server-public-key.pem || true [ -s bunq-server-public-key.pem ] \ && echo "bunq: saved bunq-server-public-key.pem (for BUNQ_CALLBACK_PUBKEY)" fi if [ "$(state_get device_registered)" != "yes" ]; then PERMITTED_IP="${BUNQ_PERMITTED_IP:-$(curl -sS --max-time 10 https://ifconfig.me)}" [ -n "$PERMITTED_IP" ] || { echo "bunq: could not determine this machine's IP" >&2; exit 1; } echo "bunq: binding the key to $PERMITTED_IP (this machine only — NOT the server)" printf 'bunq: this is permanent for this key. Continue? [y/N] ' read -r _yn; [ "$_yn" = y ] || [ "$_yn" = Y ] || { echo "aborted"; exit 1; } _resp=$(api POST /v1/device-server \ "$(python3 -c 'import json,sys print(json.dumps({"description":"catcrafts.net financials callback registrar", "secret":sys.argv[1],"permitted_ips":[sys.argv[2]]}))' \ "$BUNQ_KEY" "$PERMITTED_IP")" "$INSTALL_TOKEN") die_on_error "$_resp" state_set device_registered yes fi # Sessions expire, so this one is not cached. _resp=$(api POST /v1/session-server \ "$(python3 -c 'import json,sys; print(json.dumps({"secret":sys.argv[1]}))' "$BUNQ_KEY")" \ "$INSTALL_TOKEN") die_on_error "$_resp" SESSION=$(printf '%s' "$_resp" | json_get token) # NOT a naive search for "id": the session response opens with an Id object of # its own, and taking that one silently addresses every later call to the # wrong user. The user is whichever of these three the account type produces. USER_ID=$(printf '%s' "$_resp" | python3 -c 'import json,sys d=json.load(sys.stdin) for item in d.get("Response",[]): for k in ("UserPerson","UserCompany","UserApiKey"): u=item.get(k) if isinstance(u,dict) and "id" in u: print(u["id"]); sys.exit(0)') [ -n "$SESSION" ] && [ -n "$USER_ID" ] \ || { echo "bunq: could not open a session" >&2; exit 1; } # ── commands ────────────────────────────────────────────────────────── case "${1:-list}" in list) echo "bunq: user $USER_ID" echo echo "accounts (the donations one goes in donation_accounts in the rules file):" api GET "/v1/user/$USER_ID/monetary-account?count=50" "" "$SESSION" | python3 -c 'import json,sys d=json.load(sys.stdin) for item in d.get("Response",[]): for kind,acc in item.items(): if not isinstance(acc,dict) or "id" not in acc: continue iban=next((a.get("value") for a in acc.get("alias",[]) if a.get("type")=="IBAN"),"") bal=(acc.get("balance") or {}).get("value","?") aid=acc.get("id"); st=acc.get("status",""); desc=acc.get("description","") print(f" id={aid:<10} {st:<8} {kind:<22} {desc} {iban} balance {bal}")' echo # Filters registered PER ACCOUNT do not appear in the user-level list, so # showing only that one reads as "nothing is registered" when in fact # everything is. Both are printed, per account, or this command lies. echo "MUTATION filters, per account:" api GET "/v1/user/$USER_ID/monetary-account?count=50" "" "$SESSION" | python3 -c 'import json,sys d=json.load(sys.stdin) ids=[] for item in d.get("Response",[]): for acc in item.values(): if isinstance(acc,dict) and "id" in acc: ids.append(str(acc["id"])) print(" ".join(ids))' > /tmp/.cc-accts.$$ for _a in $(cat /tmp/.cc-accts.$$); do _f=$(api GET "/v1/user/$USER_ID/monetary-account/$_a/notification-filter-url" "" "$SESSION" \ | python3 -c 'import json,sys d=json.load(sys.stdin) out=[] for item in d.get("Response",[]): f=item.get("NotificationFilterUrl") or {} if not f: continue t=f.get("notification_target") or "" # never print the callback secret: the last path segment is masked parts=t.rsplit("/",1) masked=parts[0]+"/"+(parts[1][:4]+"…" if len(parts)>1 and parts[1] else "") out.append(f.get("category","?")+" -> "+masked) print("; ".join(out) if out else "(none)")') printf ' account %-10s %s\n' "$_a" "$_f" done rm -f /tmp/.cc-accts.$$ echo echo "user-level filters (apply to ALL accounts):" api GET "/v1/user/$USER_ID/notification-filter-url" "" "$SESSION" | python3 -c 'import json,sys d=json.load(sys.stdin) r=d.get("Response") or [] if not r: print(" (none)"); raise SystemExit for item in r: f=item.get("NotificationFilterUrl") or {} t=f.get("notification_target") or "" # The callback URL ENDS IN A SHARED SECRET. Never print it whole: this # output gets pasted into issues and terminals that keep scrollback. parts=t.rsplit("/",1) masked=parts[0]+"/"+(parts[1][:4]+"…" if len(parts)>1 and parts[1] else "") accts=f.get("all_monetary_account_id") or [] cat=f.get("category","?") print(f" {cat} -> {masked} accounts={sorted(set(accts))}")' ;; set) ACCOUNT="${2:?usage: tools/bunq-callback.sh set }" URL="${3:?usage: tools/bunq-callback.sh set }" case "$URL" in https://*) ;; *) echo "bunq: the callback URL must be https" >&2; exit 1 ;; esac # Refuse to point bunq at an endpoint that is not answering yet: a filter # whose target keeps failing is a filter bunq may disable, and a mutation # delivered into a 404 is simply lost until the weekly reconciliation. echo "bunq: checking the endpoint is live before registering it" _code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST \ -H 'content-type: application/json' -d '{}' "$URL" || echo 000) if [ "$_code" != 200 ]; then echo "bunq: $URL answered $_code, not 200." >&2 echo " Deploy the financials build and set BUNQ_CALLBACK_SECRET first." >&2 echo " (A wrong secret answers 404 by design — check the secret too.)" >&2 exit 1 fi # "all" registers at USER level, covering every account including ones # created later. Prefer it: bunq treats even per-account registrations as # user-scoped entries anyway, and repeated per-account POSTs accumulate # duplicate account ids rather than replacing cleanly. A user-level POST # replaces the ENTIRE set, which is also how you clear stale URLs. if [ "$ACCOUNT" = all ]; then echo "bunq: installing the MUTATION filter for ALL accounts" _resp=$(api POST "/v1/user/$USER_ID/notification-filter-url" \ "$(python3 -c 'import json,sys print(json.dumps({"notification_filters":[ {"category":"MUTATION","notification_target":sys.argv[1]}]}))' "$URL")" "$SESSION") die_on_error "$_resp" echo " done (previous filters replaced)" exit 0 fi echo "bunq: installing the MUTATION filter on account $ACCOUNT" # POST REPLACES the whole filter set for this account, so this is also how # you change or clear one. _resp=$(api POST "/v1/user/$USER_ID/monetary-account/$ACCOUNT/notification-filter-url" \ "$(python3 -c 'import json,sys print(json.dumps({"notification_filters":[ {"category":"MUTATION","notification_target":sys.argv[1]}]}))' "$URL")" "$SESSION") die_on_error "$_resp" printf '%s\n' "$_resp" | python3 -m json.tool echo echo "bunq: done. Send yourself €0.01 and watch:" echo " ssh hetzner journalctl -u catcrafts-server -f" ;; payments) # What a real mutation actually looks like, WITHOUT printing what a real # mutation actually says. This exists to answer "which field tells me a # payment came from bunq.me?" empirically rather than from memory — so it # prints the SHAPE (key names, and the values of fields that classify # rather than identify) and masks everything that names a human. ACCOUNT="${2:?usage: tools/bunq-callback.sh payments [count]}" api GET "/v1/user/$USER_ID/monetary-account/$ACCOUNT/payment?count=${3:-25}" \ "" "$SESSION" | python3 -c 'import json,sys SAFE={"id","created","type","sub_type","amount","payment_auto_allocate_instance", "bunqme_fundraiser_result","request_reference_split_the_bill", "payment_arrival_expected","merchant_reference","batch_id","scheduled_id"} d=json.load(sys.stdin) rows=[item["Payment"] for item in d.get("Response",[]) if "Payment" in item] if not rows: print(" (no payments on this account)"); raise SystemExit allkeys=set() for p in rows: allkeys.update(p.keys()) print(f" {len(rows)} payment(s). Union of keys present:") for k in sorted(allkeys): mark=" <- SAFE to classify on" if k in SAFE else "" print(f" {k}{mark}") print() print(" per payment (identifying fields masked):") for p in rows: amt=(p.get("amount") or {}) val=amt.get("value","?"); cur=amt.get("currency","?") bm=p.get("bunqme_fundraiser_result") bmk="yes" if bm else "no" extra="" if isinstance(bm,dict): extra=" bunqme_keys=" + ",".join(sorted(bm.keys())) pid=p.get("id"); ptype=p.get("type"); psub=p.get("sub_type") print(f" id={pid} type={ptype} sub_type={psub} " f"amount={val} {cur} bunqme_fundraiser_result={bmk}{extra}")' ;; backfill) # The categorisation worklist for a date window. READ ONLY — it publishes # nothing and writes nothing to the server. Incoming and outgoing are # separated because they ask different questions ("is this a donation?" vs # "which expense category?"), and outgoing is grouped by counterparty # because that is the unit a rule matches on. SINCE="${2:?usage: tools/bunq-callback.sh backfill [YYYY-MM-DD]}" UNTIL="${3:-9999-12-31}" _tmp=$(mktemp -d) api GET "/v1/user/$USER_ID/monetary-account?count=50" "" "$SESSION" | python3 -c 'import json,sys d=json.load(sys.stdin) for item in d.get("Response",[]): for acc in item.values(): if isinstance(acc,dict) and "id" in acc: print(acc["id"], (acc.get("description") or "?").replace(" ","_"))' > "$_tmp/accts" while read -r _id _nm; do api GET "/v1/user/$USER_ID/monetary-account/$_id/payment?count=200" "" "$SESSION" \ > "$_tmp/pay-$_id-$_nm.json" done < "$_tmp/accts" python3 - "$SINCE" "$UNTIL" "$_tmp" <<'PY' import glob, json, os, sys since, until, tmp = sys.argv[1], sys.argv[2], sys.argv[3] rows=[] for path in sorted(glob.glob(os.path.join(tmp,"pay-*.json"))): label=os.path.basename(path)[4:-5] try: d=json.load(open(path)) except Exception: continue for item in d.get("Response",[]): p=item.get("Payment") if not isinstance(p,dict): continue created=(p.get("created") or "")[:10] if not (since <= created <= until): continue amt=(p.get("amount") or {}) if amt.get("currency")!="EUR": continue try: cents=int(round(float(amt.get("value","0"))*100)) except Exception: continue cp=(p.get("counterparty_alias") or {}) rows.append({"acct":label,"date":created,"cents":cents, "type":p.get("type"),"sub":p.get("sub_type"), "name":cp.get("display_name") or "?", "iban":cp.get("iban") or "", "desc":(p.get("description") or "").strip()[:44]}) if not rows: print(" no EUR payments in", since, "..", until); raise SystemExit print(f" window {since} .. {until} {len(rows)} payment(s)\n") ins=[r for r in rows if r["cents"]>0] outs=[r for r in rows if r["cents"]<0] print(f" INCOMING ({len(ins)}) — decide donation / sale payout / other:") for r in sorted(ins,key=lambda r:r["date"]): eur=r["cents"]/100 print(f" {r['date']} {eur:>9.2f} {r['type']:<11}{r['sub']:<9} {r['name'][:22]:<22} {r['desc']}") print(f" ---- incoming total: {sum(r['cents'] for r in ins)/100:.2f}\n") print(f" OUTGOING ({len(outs)}) — grouped by counterparty; each group is one rule:") groups={} for r in outs: key=(r["name"],r["iban"]) g=groups.setdefault(key,{"cents":0,"n":0,"descs":set()}) g["cents"]+=r["cents"]; g["n"]+=1 if r["desc"]: g["descs"].add(r["desc"]) for (name,iban),g in sorted(groups.items(),key=lambda kv:kv[1]["cents"]): eur=-g["cents"]/100 sample=sorted(g["descs"])[0] if g["descs"] else "" print(f" {eur:>10.2f} x{g['n']:<3} {name[:26]:<26} {iban:<20} {sample[:30]}") print(f" ---- outgoing total: {-sum(r['cents'] for r in outs)/100:.2f}") PY rm -rf "$_tmp" ;; payment) # One payment by id, classification fields only. Used to answer "what does # a payment that AROSE FROM a bunq.me tab actually look like on the wire?" ACCOUNT="${2:?usage: tools/bunq-callback.sh payment ...}" shift 2 for pid in "$@"; do api GET "/v1/user/$USER_ID/monetary-account/$ACCOUNT/payment/$pid" "" "$SESSION" \ | python3 -c 'import json,sys d=json.load(sys.stdin) rows=[i["Payment"] for i in d.get("Response",[]) if "Payment" in i] for p in rows: amt=(p.get("amount") or {}) val=amt.get("value","?") pid=p.get("id"); ptype=p.get("type"); psub=p.get("sub_type") has_bm="bunqme_fundraiser_result" in p print(f" id={pid} type={ptype} sub_type={psub} amount={val} " f"has_bunqme_field={has_bm}")' done ;; bunqme) # The bunq.me side. A tab records which payments fulfilled it, so this is # the only authoritative "did this money come from a bunq.me link?" join — # and note it takes an API CALL WITH THE KEY, which is exactly what the # server does not have. Prints payment ids so they can be matched against # `payments` output; no payer names or links are shown in full. ACCOUNT="${2:?usage: tools/bunq-callback.sh bunqme [count]}" api GET "/v1/user/$USER_ID/monetary-account/$ACCOUNT/bunqme-tab?count=${3:-25}" \ "" "$SESSION" | python3 -c 'import json,sys d=json.load(sys.stdin) tabs=[i["BunqMeTab"] for i in d.get("Response",[]) if "BunqMeTab" in i] if not tabs: print(" (no bunq.me tabs on this account)"); raise SystemExit for t in tabs: url=t.get("bunqme_tab_share_url") or "" slug=url.rsplit("/",1)[-1] masked=slug[:3]+"…" if slug else "(none)" entry=t.get("bunqme_tab_entry") or {} inq=t.get("result_inquiries") or [] pids=[] for r in inq: node=r.get("BunqMeTabResultInquiry") or r pay=(node.get("payment") or {}) pay=pay.get("Payment") or pay if isinstance(pay,dict) and pay.get("id") is not None: pids.append(str(pay["id"])) tid=t.get("id"); st=t.get("status") amt=(entry.get("amount_inquired") or {}).get("value","open") joined=",".join(pids) print(f" tab={tid} status={st} link=bunq.me/{masked} asked={amt} " f"fulfilled_by_payment_ids=[{joined}]")' ;; *) echo "usage: tools/bunq-callback.sh [list | set |" >&2 echo " payments [count] | bunqme [count]]" >&2 exit 2 ;; esac