ejosterberg / opensalestax-invoice-ninja
Sidecar webhook listener that adds destination-based US sales tax to Invoice Ninja v5 invoices via the self-hosted OpenSalesTax engine
Package info
github.com/ejosterberg/opensalestax-invoice-ninja
Type:project
pkg:composer/ejosterberg/opensalestax-invoice-ninja
Requires
- php: >=8.2
- ext-json: *
- ejosterberg/opensalestax: ^0.1
- guzzlehttp/guzzle: ^7.8
- psr/log: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.10
- dev-main
- v0.2.1
- v0.2.0
- v0.2.0-alpha.1
- v0.1.0
- v0.1.0-alpha.1
- dev-dependabot/composer/squizlabs/php_codesniffer-tw-4.0
- dev-dependabot/composer/phpstan/phpstan-tw-2.1
- dev-dependabot/github_actions/actions/checkout-6
- dev-dependabot/github_actions/actions/cache-5
- dev-dependabot/github_actions/actions/upload-artifact-7
- dev-feat/v0.2-webhook-signing-shim
This package is auto-updated.
Last update: 2026-05-17 17:58:55 UTC
README
v0.1.0-alpha.1. Installable; passes 81 unit tests; SonarQube quality gate clean (0/0/0/0); not yet validated against a real Invoice Ninja v5 storefront. See
specs/for the build plan.
A free, self-hostable webhook sidecar that adds destination-based US sales tax to Invoice Ninja v5 invoices via the OpenSalesTax engine. No per-transaction fees, no SaaS lock-in — small agencies and freelancers self-host both Invoice Ninja and the OpenSalesTax engine on their own infrastructure.
How it works (sidecar model)
+--------------+ 1. webhook /webhooks/invoice-ninja +-----------+
| Invoice | ---------------------------------------> | Sidecar |
| Ninja v5 | | (this) |
| | 3. PUT /api/v1/invoices/{id} | |
| | <--------------------------------------- | |
+--------------+ +-----------+
|
2. /v1/calculate v
+-----------+
| OpenSales |
| Tax engine|
+-----------+
- Invoice Ninja fires a webhook (e.g.
invoice.created) at the sidecar. - The sidecar pulls the destination ZIP and line items, calls the OpenSalesTax engine, gets a calculated tax rate.
- The sidecar writes the rate back to the invoice via Invoice Ninja's REST API (
PUT /api/v1/invoices/{id}withtax_name1/tax_rate1).
The whole loop completes in well under a second. If anything goes wrong (engine unreachable, malformed payload, non-US destination) the sidecar fails soft — the invoice is left untaxed and the operator sees a structured log line, rather than the customer seeing a broken invoice.
Why a sidecar and not a Laravel package?
Invoice Ninja v5 does not publish a stable package-extension SPI for in-process tax providers; the supported integration surfaces are its REST API and its webhook subscriber list. The sidecar pattern uses both of those — meaning it doesn't require modifying Invoice Ninja's source tree and survives Invoice Ninja upgrades without regressions. See specs/decisions/001-shape-a-vs-shape-b.md for the full architectural decision record.
What this sidecar does NOT do
- File or remit tax (calculation only — the merchant remits)
- Validate addresses
- Handle non-USD currencies or non-US destinations (returns 204, leaves the invoice alone)
- Validate tax-exempt customer certificates
- Ship with the engine bundled — point it at your own OpenSalesTax engine
Disclaimer
Tax calculations are provided as-is for convenience. The merchant is solely responsible for tax-collection accuracy and remittance to the appropriate jurisdictions. Verify against your state Department of Revenue before remitting.
Compatibility matrix
| Component | Tested | Notes |
|---|---|---|
| Invoice Ninja v5 | ✔ (alpha — live-test pending) | v4 is EOL and unsupported. |
| OpenSalesTax engine | v0.55.x | Tracks the engine's v1 HTTP API. |
| PHP | 8.1, 8.2, 8.3 | CI matrix. |
| OS | Linux | Tested on Debian 13. Should run on any POSIX with PHP-FPM. |
Install
composer create-project ejosterberg/opensalestax-invoice-ninja /opt/ost-in-sidecar cd /opt/ost-in-sidecar cp .env.example .env # edit .env with your values
Configure (env vars)
| Var | Required | Default | Purpose |
|---|---|---|---|
OST_ENGINE_URL |
yes | — | Base URL of your OpenSalesTax engine (e.g. http://10.0.0.5:8080) |
OST_API_KEY |
no | — | Bearer token if the engine requires auth |
OST_TIMEOUT_SECONDS |
no | 10 |
Outbound HTTP timeout, range (0, 60] |
IN_API_URL |
yes | — | Base URL of your Invoice Ninja instance |
IN_API_TOKEN |
yes | — | Invoice Ninja API token (X-Api-Token header) |
IN_WEBHOOK_SIGNING_SECRET |
yes | — | HMAC-SHA256 secret shared with Invoice Ninja; min 32 chars |
SIDECAR_ALLOW_PRIVATE_NETWORKS |
no | 1 |
Allow RFC1918 destinations (same-VM deployment). Set 0 if exposed to the internet. |
SIDECAR_REPLAY_WINDOW_SECONDS |
no | 300 |
Max age of a signed webhook before it's rejected as replay, range [30, 3600] |
SIDECAR_TLS_VERIFY |
no | 1 |
TLS peer-verify on outbound calls |
SIDECAR_RATE_LIMIT_PER_MINUTE |
no | 120 |
Per-source-IP rate limit on the inbound webhook endpoint |
Run
For development:
php -S 0.0.0.0:8181 bin/sidecar.php
For production, behind nginx + PHP-FPM. The sidecar exposes two paths:
GET /health— health probe, returns{"status":"ok",...}POST /webhooks/invoice-ninja— the webhook endpoint Invoice Ninja calls
Wire up the Invoice Ninja webhook
In Invoice Ninja, Settings → Integrations → Webhooks, create a subscriber:
- URL:
https://your-sidecar-host/webhooks/invoice-ninja - Event:
invoice.created(andinvoice.updatedif you want recalculation on edit) - Method: POST
Then sign each request with HMAC-SHA256 of t.body (Stripe-style) and include the X-Sidecar-Signature: t=<unix-seconds>,v1=<hex-digest> header. Unsigned requests are rejected with 401.
Invoice Ninja v5's stock webhook subscriber emits unsigned POSTs, so this repo ships a companion Laravel signing shim that closes the gap — see middleware/ (Composer package: ejosterberg/opensalestax-invoice-ninja-shim). One-line install:
composer require ejosterberg/opensalestax-invoice-ninja-shim
Walkthrough in docs/SIGNING-SHIM.md and middleware/docs/SHIM-INSTALL.md.
Security
The sidecar exposes an inbound HTTP endpoint and writes back to Invoice Ninja with admin credentials, so it has a meaningful threat surface. The full threat model and mitigations are in docs/SECURITY-REVIEW.md. Key defenses:
- HMAC signature verification on every inbound request, constant-time compare
- Replay protection via timestamp window + body-hash cache
- Rate-limit per source IP
- SSRF guard on outbound URLs (rejects file://, ftp://, link-local, etc.)
- TLS verification ON by default
- No secrets in logs — API keys / tokens redacted in the structured logger
- No PII in logs — customer addresses and full payloads are never logged
Calculation-only
This sidecar calculates. The merchant remits.
Development
composer install
composer check # phpunit + phpstan + phpcs + composer audit
See CONTRIBUTING.md for the DCO sign-off requirement and quality gate.
License
Dual-licensed under your choice of Apache-2.0 OR GPL-2.0-or-later. See LICENSE.
Related projects
ejosterberg/opensalestax— the tax-calculation engineejosterberg/opensalestax-php— the PHP SDK this sidecar depends onejosterberg/opensalestax-invoice-ninja-shim— companion Laravel signing shim installed inside Invoice Ninja v5 (this repo,middleware/sub-package)ejosterberg/opensalestax-magento— sibling connector for Magento 2ejosterberg/opensalestax-medusa— sibling connector for Medusa.js