#!/bin/sh # Tell bunq where to push payment notifications. # # Run from a machine whose address the bunq API key permits — the web host is # not one, which is the whole reason webhooks exist in this setup. The session # it uses comes from the context file that tools/pull-and-ship-credits.sh # created, so run that at least once first. # # tools/register-bunq-webhook.sh 'https://catcrafts.net/hooks/bunq/' # tools/register-bunq-webhook.sh --show # list what is registered # # THIS REPLACES THE WHOLE FILTER SET. bunq treats the POST as "here is the # complete list", so a call that omits an existing filter deletes it. Every # filter wanted has to be in the one request, which is why --show exists: look # before overwriting. # # The target URL is never echoed: it is a shared secret, and this script's # output tends to end up pasted into issues and chat logs. set -eu cd "$(dirname "$0")/.." CTX="${CREDITS_LOCAL:-$HOME/.cache/catcrafts}/orders.jsonl.transfer-credits.jsonl.bunq-context.json" [ -f "$CTX" ] || { echo "$0: no bunq context at $CTX — run tools/pull-and-ship-credits.sh first" >&2; exit 1; } SESSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["session_token"])' "$CTX") USERID=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["user_id"])' "$CTX") [ -n "$SESSION" ] && [ "$USERID" != "0" ] || { echo "$0: context has no session — run the pull once" >&2; exit 1; } API="https://api.bunq.com/v1/user/$USERID/notification-filter-url" HDRS="-H Content-Type:application/json -H X-Bunq-Client-Authentication:$SESSION -H User-Agent:catcrafts.net-register/1.0" if [ "${1:-}" = "--show" ]; then # Redact the secret out of the response before it reaches a terminal. # shellcheck disable=SC2086 curl -s $HDRS "$API" | python3 -c ' import json,sys,re try: d=json.load(sys.stdin) except Exception: print("could not parse bunq response"); sys.exit(1) for item in d.get("Response",[]): f=item.get("NotificationFilterUrl") or {} t=f.get("notification_target","") print(" category=%-22s target=%s" % (f.get("category","?"), re.sub(r"(/hooks/bunq/).*", r"\1", t))) ' exit 0 fi TARGET="${1:-}" case "$TARGET" in https://*) ;; *) echo "$0: pass the full https:// callback URL (bunq refuses plain http)" >&2; exit 2 ;; esac # MUTATION alone: it fires on every balance change and carries the Payment # object, which is exactly what the handler decodes. Adding PAYMENT as well # would deliver most events twice, and while the handler deduplicates on the # payment id, doubling the traffic to buy nothing is not a trade. BODY=$(python3 -c ' import json,sys # No notification_delivery_method: bunq rejects it as "Superfluous" on this # endpoint, because /notification-filter-url already means URL delivery. print(json.dumps({"notification_filters":[ {"notification_target":sys.argv[1], "category":"MUTATION"}]})) ' "$TARGET") # shellcheck disable=SC2086 OUT=$(printf '%s' "$BODY" | curl -s -w '\n%{http_code}' $HDRS -X POST --data-binary @- "$API") CODE=$(printf '%s' "$OUT" | tail -1) if [ "$CODE" = 200 ]; then echo "$0: registered MUTATION callbacks (target not shown)" echo "$0: verify with $0 --show" else # The body can echo the target back, so redact before printing an error. printf '%s\n' "$OUT" | sed 's#\(/hooks/bunq/\)[^"]*#\1#g' >&2 echo "$0: bunq refused the registration (HTTP $CODE)" >&2 exit 1 fi