- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .env-example | ||
| .gitignore | ||
| LICENSE | ||
| README.md | ||
| requirements.txt | ||
| sync_memberships.py | ||
| webhook_receiver.py | ||
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
- Primary (
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.jsonto 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 causeModuleNotFoundErrorat 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