Syncs Invoice Ninja subscription payments to Discourse group membership via API and webhook
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-28 14:47:27 +00:00
.env-example Add timeout=30 to all requests calls; update .env-example for dual-Discourse setup 2026-08-25 22:45:49 +00:00
.gitignore Replace Discourse invite API check with local tracking; fix repeated invite bug 2026-08-26 14:18:51 +00:00
LICENSE Add GPL v3 license 2026-08-21 16:25:48 +00:00
README.md Updated to reflect move to two-instance setup 2026-08-26 16:12:53 +00:00
requirements.txt Add python-dotenv to requirements 2026-08-21 18:08:52 +00:00
sync_memberships.py removed run time from sync report 2026-08-28 14:46:24 +00:00
webhook_receiver.py Fix webhook to use venv Python instead of system Python 2026-08-25 23:00:34 +00:00

invoiceninja-discourse-sync

Automatically grants and revokes Discourse group membership based on active paid subscriptions in Invoice Ninja.

For complete step-by-step setup instructions including nginx proxy configuration, Invoice Ninja webhook setup, and troubleshooting: 👉 Sync Invoice Ninja subscriptions to Discourse group membership

Note: this is vibe-coded! If you find it useful, please let me know and share any suggestions or feedback.

What it does

  • sync_memberships.py — queries the Invoice Ninja API and syncs Discourse group membership for all active subscribers. Supports two Discourse targets:
    • Primary (premium.example.com) — full sync with invitations, welcome PMs, primary group/flair, and an admin summary PM after each run
    • Secondary (community.example.com) — lightweight group-only sync for subscribers who already have an account there; no invitations, no PMs
  • webhook_receiver.py — a lightweight Flask app that listens for Create Payment webhooks from Invoice Ninja and triggers an immediate sync, so new members get access within seconds of payment rather than waiting for the next cron run.

Together they provide real-time sync (webhook) and a daily safety net (cron at 6am).

How new members are handled

  • If the person already has an account on the primary Discourse, they are added directly to the appropriate group.
  • If they don't have an account yet, they receive an email invitation. On acceptance they are automatically added to the correct group. Pending invitations are tracked locally in invited_emails.json — the script will not send duplicate invites.
  • Upgrades and downgrades between plans are handled gracefully — members are moved between groups as needed.
  • A welcome PM is sent to each new member using a template fetched from a designated Discourse templates category. Welcomed users are tracked in welcomed.json to prevent duplicate welcome PMs.
  • Primary group and title (flair) are set on add and cleared on remove. If the Discourse instance does not support primary group persistence via the API, the script logs a warning and continues without counting it as a change.

Discourse groups

The script manages two groups on the primary Discourse instance:

Group Plan
premium_solo Premium Solo
premium_group Premium Group

The same group names must exist on the secondary Discourse instance. Group IDs are configured separately per instance in .env since they differ between sites.

Requirements

  • Python 3.12+
  • python3.12-venv (for virtual environment)
  • A working Invoice Ninja instance with API access
  • Two Discourse instances, each with a global API key:
    • Primary — invitation-only; receives full sync including invitations and PMs
    • Secondary — public community; receives group-only sync for existing members
  • Discourse groups created on both instances for each membership tier
  • Discourse topics created as PM templates in a private category on the primary instance
  • systemd (for running the webhook receiver as a persistent service)
  • nginx proxying the webhook endpoint — see setup runbook

Installation

git clone git@git.digitallysovereign.org:tobias/invoiceninja-discourse-sync.git \
  /opt/invoiceninja-discourse-sync
cd /opt/invoiceninja-discourse-sync

Set up the virtual environment

apt install python3.12-venv
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

Configuration

1. Create the .env file

cp .env-example .env
nano .env
chmod 600 .env

Fill in all values — see .env-example for descriptions of each variable.

⚠️ Never commit .env — it contains secrets. It is excluded by .gitignore.

2. Get Discourse group IDs

Group IDs differ between Discourse instances. Find them by appending .json to the group URL:

https://premium.example.com/g/premium_solo.json   → look for "id" field
https://community.example.com/g/premium_solo.json → look for "id" field

Add all four IDs to .env — see .env-example.

3. Generate the webhook token

The webhook receiver authenticates incoming requests using a shared secret:

python3 -c "import secrets; print(secrets.token_hex(32))"

Add the output to .env as WEBHOOK_TOKEN. Also enter it in Invoice Ninja as the X-Webhook-Token webhook header value (see setup runbook).

4. Create PM templates in Discourse

Create one topic per template in a private category on the primary Discourse (e.g. #internal:templates). Paste each topic ID into .env.

Templates support these placeholders:

Placeholder Description
{username} Discourse username
{tier_label} Membership plan name
{invoice_date} Subscription start date
{next_due_date} Next renewal date
{client_portal_url} Invoice Ninja client portal URL

Required templates: welcome-premium-solo, welcome-premium-group, membership-changed, membership-lapsed.

5. Set up the systemd service

nano /etc/systemd/system/membership-webhook.service
[Unit]
Description=Invoice Ninja → Discourse membership webhook receiver
After=network.target

[Service]
ExecStart=/opt/invoiceninja-discourse-sync/venv/bin/python3 /opt/invoiceninja-discourse-sync/webhook_receiver.py
Restart=always
RestartSec=5
User=root
WorkingDirectory=/opt/invoiceninja-discourse-sync
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable membership-webhook
systemctl start membership-webhook

⚠️ The service must use the venv Python (venv/bin/python3), not the system Python. Using the system Python will cause ModuleNotFoundError at runtime.

6. Set up the cron job

crontab -e
0 6 * * * /opt/invoiceninja-discourse-sync/venv/bin/python3 /opt/invoiceninja-discourse-sync/sync_memberships.py >> /var/log/sync_memberships.log 2>&1

⚠️ Do not set the cron to run more frequently than daily. Each run makes multiple API calls to both Discourse instances and may take several minutes to complete. Overlapping runs will produce duplicate log entries and admin PMs.

Runtime files

These files are created automatically at runtime and excluded from git:

File Purpose
welcomed.json Tracks which Discourse usernames have received a welcome PM
invited_emails.json Tracks which emails have been sent an invite (with timestamp)

If you need to re-send a welcome PM or re-invite someone, remove their entry from the appropriate file.

Usage

Run the sync manually:

cd /opt/invoiceninja-discourse-sync
source venv/bin/activate
python3 sync_memberships.py

Check the webhook receiver:

systemctl status membership-webhook
journalctl -u membership-webhook -f

Test the webhook endpoint:

curl -X POST https://your-invoiceninja-domain/membership-sync \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Token: your_webhook_token" \
  -d '{"test": true}'

Expect a 200 response. A 403 means the token doesn't match. A 502 means nginx cannot reach the Flask app.

Check the sync log:

tail -f /var/log/sync_memberships.log

Update scripts from the repo:

cd /opt/invoiceninja-discourse-sync
git pull
systemctl restart membership-webhook

License

GPL-3.0-or-later