__ __ _ __ __ _ ___ ___
\ \ / /__ _ _ _| |/ _|/ /_____ __ __ /_\ | _ \_ _|
\ \/\/ / _ \ '_| | _| | / _ \ V V / / _ \ | _/ | |
\_/\_/\___/_| |_|_| |_|_\___/\_/\_/ /_/ \_\|_| |___|
Turn any HTTP workflow into a protected, rate-limited, monetizable API.
Workflow API is a small self-hosted gateway for n8n, Zapier, custom scripts, or any workflow with a webhook URL. It sits in front of your workflow and adds:
- API key authentication
- Per-key rate limits
- Optional key expiration
- Optional per-workflow key scoping
- JSON access logs
- Usage stats and a read-only dashboard
- Optional Stripe webhook automation for subscription-based key creation and revocation
- 48-hour grace period on subscription cancellation with automatic reactivation
- HMAC webhook signature verification on all Stripe events
Workflow API does not run your workflow, process payments, or require a database. Everything lives in config.yaml and logs/.
Workflow API acts as a transparent, high-performance gateway between your customers and your backend workflows. It excels at protecting lead generation endpoints, AI app routing, or SaaS integrations.
Customer or app
-> Workflow API endpoint, for example /run/generate-lead
-> API key validation
-> per-key rate limit check
-> optional gateway scope check
-> your workflow webhook URL
-> response returned unchanged
GET vs POST Requests:
Workflow API completely supports both GET and POST methods. When configuring a workflow in config.yaml, you declare the HTTP method it listens to. If you need a single endpoint strategy that accommodates both (for example, fetching records and creating records), you simply register two workflow configurations spanning both methods, and your user's API key will seamlessly handle both limits.
How it Works with SQL Databases:
Workflow API uses an embedded local SQLite database (workflow-api.db) to quickly handle key hashing, rate limits, and billing without forcing you to set up an external database.
If your specific business logic (e.g. fetching lead-gen data) requires querying Postgres or MySQL, you connect that SQL Database directly to your Workflow Engine (like n8n), NOT to Workflow API.
- The user sends a request to the Workflow API.
- The API secures, rate-limits, and forwards it to n8n.
- n8n queries your raw PostgreSQL/MySQL database.
- n8n returns the data, which Workflow API securely bounces back to the user. This separation of concerns means you can protect literally any tech stack without rewriting connection code.
In this README, "gateway" means a configured workflow entry. The current CLI writes these under workflows: in config.yaml, and Workflow API also supports a gateways: section for newer configs.
How keys are created usually depends on whether you're selling access or creating internal tooling. Workflow API fully supports both out of the box:
If you are generating income by selling access to your API:
- You share a Stripe Payment Link.
- The user buys a subscription.
- Stripe hits Workflow API's background webhook mechanism.
- Workflow API instantly provisions a new scoped, rate-limited key, embeds it in an HTML email, and dispatches it automatically to the buyer. Passive income, zero manual work.
For internal use, individual clients, or free access, you can run a single command on your terminal to instantly mint a key:
workflow-api keys create --name "Client A" --rate-limit 120This produces a hash-safeguarded secure key (wfapi-...) that you can directly send to your client via Slack/Email.
When you (or the Stripe integration) generate an API key, the system does not save the actual API key.
Instead, it runs the raw key through a one-way mathematical function called a SHA-256 Hash, and only saves that scrambled hash in your workflow-api.db SQLite database (or your config.yaml depending on configuration).
Because the key is securely hashed, neither you nor an attacker can reverse-engineer the original key. If a customer loses their key, there is no "Show API Key" button—you simply revoke the old one and generate a fresh key for them.
- Python 3.11 or newer
- A reachable workflow/webhook URL, for example an n8n Webhook node URL
- Optional: Docker for VPS deployment
- Optional: Stripe account and Stripe CLI for subscription automation
On many machines the command is python3, not python. The examples below use python3.
git clone https://github.com/yourusername/workflow-api.git
cd workflow-api
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtOptional convenience alias:
alias workflow-api="python3 cli.py"If you do not add the alias, use python3 cli.py anywhere this README shows workflow-api.
For n8n, the easiest path is the one-command setup:
python3 cli.py n8n --url http://localhost:5678/webhook-test/n8ntest2 --name n8ntest --forceThis writes config.yaml, creates a scoped test API key, and prints the exact curl command to test your Workflow API endpoint.
If you want the step-by-step wizard instead, run:
Run the setup wizard:
workflow-api initYou will be asked for:
Workflow name: my-workflow
Webhook / target URL: http://localhost:5678/webhook/your-webhook-id
Endpoint path: /run/my-workflow
HTTP method: POST
Port: 8000
Create first API key now: Yes
This creates config.yaml.
Example:
workflows:
- name: my-workflow
endpoint: /run/my-workflow
target: http://localhost:5678/webhook/your-webhook-id
method: POST
keys:
- name: Pro
key: wfapi-example
rate_limit_per_minute: 100
created_at: "2026-04-21"
expires_at: null
allowed_gateways: null
logging:
file: logs/usage.log
server:
host: 0.0.0.0
port: 8000You normally do not need to edit config.yaml manually except for optional admin and Stripe settings.
Make sure your workflow service is running first. For n8n, that usually means n8n is running and the webhook URL is active.
Then start Workflow API:
workflow-api startWorkflow API will listen on the configured port, usually:
http://localhost:8000
Interactive API docs are available at:
http://localhost:8000/docs
Use the key printed by workflow-api init or create a new one:
workflow-api key create --name Pro --rate-limit 100Call the public Workflow API endpoint:
curl -X POST http://localhost:8000/run/my-workflow \
-H "Authorization: Bearer wfapi-your-key-here" \
-H "Content-Type: application/json" \
-d '{"input": "hello"}'Workflow API forwards the request body and query params to your workflow URL, then returns the workflow response.
Create an unlimited key:
workflow-api key create --name Enterprise --rate-limit 0Create a temporary trial key:
workflow-api key create --name Trial --rate-limit 20 --expires-in 30dCreate a key that expires on a specific date:
workflow-api key create --name Trial --rate-limit 20 --expires-at 2026-12-31List keys:
workflow-api key listRevoke all keys with a given name:
workflow-api key revoke Trialkey and keys both work:
workflow-api keys list
workflow-api key listBy default, a key can call every configured workflow. To limit a key to specific workflow names, use --gateways.
Example:
workflow-api key create \
--name Basic \
--rate-limit 30 \
--gateways my-workflowMultiple gateways:
workflow-api key create \
--name Pro \
--rate-limit 200 \
--gateways summarize,translateWorkflow API validates that each gateway name exists in config.yaml.
If a scoped key calls a gateway it is not allowed to use, Workflow API returns:
{"detail": "Key not authorized for this workflow"}Existing keys without allowed_gateways continue to work for all gateways.
Show the last 20 log entries:
workflow-api logsFollow logs live:
workflow-api logs --followFilter by severity:
workflow-api logs --level ERRORDefault log path:
logs/usage.log
Override the log path with an environment variable:
WORKFLOW_API_LOG_FILE=/var/log/workflow-api.log workflow-api startOr configure it in config.yaml:
logging:
file: logs/usage.logExample log line:
{"time": "2026-04-21T10:00:00Z", "level": "INFO", "event": "request", "endpoint": "/run/my-workflow", "gateway": "my-workflow", "tier": "Pro", "status": 200, "latency_ms": 142.3}Localhost can access stats and the dashboard without an admin key.
Stats:
curl http://localhost:8000/__workflow-api/statsDashboard:
http://localhost:8000/__workflow-api/dashboard
For remote access, set an admin key. You can use an environment variable:
export WORKFLOW_API_ADMIN_KEY="change-this-admin-secret"
workflow-api startThen call:
curl http://your-server:8000/__workflow-api/stats \
-H "Authorization: Bearer change-this-admin-secret"Or:
curl http://your-server:8000/__workflow-api/stats \
-H "X-Admin-Key: change-this-admin-secret"You can also store the admin key in config.yaml:
admin:
api_key: "change-this-admin-secret"Dashboard URL:
http://your-server:8000/__workflow-api/dashboard
The dashboard is read-only. It does not create, edit, or revoke anything.
Stripe automation lets Workflow API create and revoke API keys from subscription events.
What it does:
checkout.session.completedcreates a new scoped, rate-limited API key and emails it to the customer- The key scope comes from the Stripe Price ID mapping
- The key gets
stripe_subscription_idfor lifecycle tracking customer.subscription.deletedschedules key revocation after a 48-hour grace periodcustomer.subscription.createdorinvoice.payment_succeededcancels a pending revocation and keeps the key active- Duplicate Stripe events are deduplicated and ignored
- Every incoming webhook is verified via HMAC signature validation before any business logic runs
What it does not do:
- Workflow API does not manage Stripe products or prices
- Workflow API does not expose the generated key in the webhook response
Every request to /webhooks/stripe is verified using Stripe's Stripe-Signature header before any business logic executes. This prevents forged or tampered webhook payloads from reaching your system.
How it works:
Stripe sends POST /webhooks/stripe
→ Read raw body + Stripe-Signature header
→ stripe.Webhook.construct_event(body, signature, secret)
→ HMAC mismatch? → 401 Unauthorized (request rejected)
→ Signature valid? → proceed to process event
Configuration:
The webhook signing secret is loaded exclusively from the STRIPE_WEBHOOK_SECRET environment variable. It is never read from config.yaml to prevent accidental leakage into version control.
export STRIPE_WEBHOOK_SECRET="whsec_your_webhook_secret"Get this value from:
- Stripe Dashboard: Developers → Webhooks → your endpoint → Signing secret
- Stripe CLI (local testing):
stripe listen --forward-to localhost:8000/webhooks/stripeprints it
If STRIPE_WEBHOOK_SECRET is not set, the endpoint returns 503 and all webhooks are rejected.
When a customer cancels their subscription, Workflow API does not revoke the API key immediately. Instead, it schedules revocation after a 48-hour grace period. If the customer resubscribes or pays an outstanding invoice within that window, the pending revocation is cancelled and the key stays active.
Flow:
Stripe fires customer.subscription.deleted
→ Workflow API writes a pending_cancellation record to the database
→ Key stays ACTIVE for 48 hours
Case A: Customer resubscribes within 48h
→ Stripe fires customer.subscription.created or invoice.payment_succeeded
→ Workflow API deletes the pending_cancellation record
→ Key remains active ✅
Case B: 48 hours pass without reactivation
→ Background poller detects the due cancellation
→ Key is revoked 🔒
The grace period is persistent — it survives server restarts and works correctly across multiple workers because the state is stored in the database (SQLite table or YAML JSON file), not in memory.
Configuration:
# Override the default 48-hour grace period (value in seconds)
export CANCELLATION_GRACE_SECONDS=172800
# Override how often the poller checks for due revocations (default: 60s)
export CANCELLATION_POLL_SECONDS=60| Stripe Event | Action |
|---|---|
checkout.session.completed |
Create API key, map Price ID to gateway scope, email key to customer |
customer.subscription.deleted |
Schedule key revocation after 48h grace period |
customer.subscription.created |
Cancel pending revocation if one exists for this subscription |
invoice.payment_succeeded |
Cancel pending revocation if one exists for this subscription |
| Any other event | Acknowledged but ignored |
Set environment variables for secrets:
export STRIPE_WEBHOOK_SECRET="whsec_your_webhook_secret"Edit config.yaml for non-secret settings:
stripe:
api_key: "sk_live_or_test_key"
rate_limit_per_minute: 100
price_to_gateway:
"price_basic": ["my-workflow"]
"price_pro": ["my-workflow", "another-workflow"]Notes:
api_keyis used to fetch checkout line items when Stripe does not include them in the event payload.price_to_gatewaymaps Stripe Price IDs to Workflow API workflow names.rate_limit_per_minuteis optional and defaults to60for Stripe-created keys.
In Stripe, set your webhook endpoint URL to:
https://your-domain.com/webhooks/stripe
Select these events:
checkout.session.completed
customer.subscription.deleted
customer.subscription.created
invoice.payment_succeeded
For local testing with Stripe CLI:
stripe listen --forward-to localhost:8000/webhooks/stripeCopy the whsec_... secret printed by Stripe CLI into your environment:
export STRIPE_WEBHOOK_SECRET="whsec_..."Start Workflow API:
export STRIPE_WEBHOOK_SECRET="whsec_from_stripe_listen"
workflow-api startIn another terminal:
stripe trigger checkout.session.completedThen check your database. A new key should appear with:
allowed_gateways:
- my-workflow
stripe_subscription_id: sub_...Trigger deletion (with grace period):
stripe trigger customer.subscription.deletedThe key will be marked as pending cancellation. It will be revoked after 48 hours unless a reactivation event arrives.
Important: Stripe's default trigger payload may use a test Price ID that is not in your price_to_gateway mapping. If no key is created, check workflow-api logs for a stripe_price_unmapped warning and add the test Price ID to the mapping.
Workflow API is built as a pure Faceless Developer Tool. For maximum security, it does not include public-facing HTML lookups or a dashboard by default.
If you want to build your own custom GUI (e.g. in React, Next.js, or Vue):
- Admin Dashboard: Make an authenticated
GETrequest from your Frontend/Backend tohttp://your-server:8000/__workflow-api/statsusing yourX-Admin-Key. This returns a rich JSON payload containing live traffic metrics, active keys, and recent activity. - Customer Portal: If you want your customers to look up their API usage or endpoints, your custom frontend should query the
workflow-api.dbSQLite database directly (or you can use the CLI dynamically).
To deploy the Workflow API so it runs automatically 24/7—even after server reboots—choose one of these highly resilient options:
Best for developers who want a hands-off deployment. You can push workflows directly to Platform-as-a-service providers.
- Push this code to a private GitHub repo.
- Connect it to Render.com or Railway.app as a new Web Service.
- Add your
WORKFLOW_API_ADMIN_KEYas an environment variable. - Important: Attach a Persistent Disk/Volume to the
/app/workflow-api.dbfile so your API key hashes survive cloud redeploys.
Best for maximum edge-performance and total control. Place a docker-compose.yml file on an Ubuntu server:
version: '3.8'
services:
workflow-api:
build: .
container_name: workflow-api
restart: always # Guarantees 24/7 uptime unconditionally
ports:
- "8000:8000"
volumes:
- ./config.yaml:/app/config.yaml
- ./workflow-api.db:/app/workflow-api.db
- ./logs:/app/logs
environment:
- WORKFLOW_API_ADMIN_KEY=change-this-admin-secretTurn it on by typing docker-compose up -d --build. The container will instantly boot up and securely stay alive in the background.
With admin key:
docker run -d \
-p 8000:8000 \
-e WORKFLOW_API_ADMIN_KEY="change-this-admin-secret" \
-v $(pwd)/config.yaml:/app/config.yaml \
-v $(pwd)/logs:/app/logs \
--name workflow-api \
workflow-apiView container logs:
docker logs -f workflow-apiView Workflow API access logs from the mounted directory:
tail -f logs/usage.logBefore exposing Workflow API publicly:
Security:
- Put Workflow API behind HTTPS, for example Caddy, Nginx, Traefik, or a cloud load balancer.
- Set
WORKFLOW_API_ADMIN_KEYas an environment variable. - Set
STRIPE_WEBHOOK_SECRETas an environment variable (never inconfig.yaml). - Keep
config.yamlprivate because it contains API key hashes. - Use filesystem permissions so only the Workflow API user can read
config.yamlandworkflow-api.db. - Use Stripe test mode before switching to live mode.
Persistence:
- Back up
config.yamland/orworkflow-api.db. - Mount
logs/andworkflow-api.dbas persistent volumes if using Docker. - Configure log rotation for long-running deployments.
Required environment variables:
export WORKFLOW_API_ADMIN_KEY="your-admin-secret"
export STRIPE_WEBHOOK_SECRET="whsec_your_webhook_secret"
# Optional overrides
export CANCELLATION_GRACE_SECONDS=172800 # default: 48 hours
export CANCELLATION_POLL_SECONDS=60 # default: 60s
export SMTP_HOST="smtp.gmail.com"
export SMTP_PASSWORD="your-app-password"401 Invalid or missing API key
Check the header format:
Authorization: Bearer wfapi-your-key
403 API key expired
Create a new key or use one without expires_at.
403 Key not authorized for this workflow
The key has allowed_gateways and the workflow name is not included. Run:
workflow-api key listThen create a correctly scoped key:
workflow-api key create --name Pro --rate-limit 200 --gateways my-workflow429 Rate limit exceeded
The key has used its per-minute allowance. Create a higher-tier key or wait for the bucket to refill.
502 Could not reach workflow
Workflow API is running, but your target workflow URL is not reachable. Check that n8n, Zapier, or your app is running and that the target URL in config.yaml is correct.
Stripe webhook returns 401
The Stripe-Signature header failed HMAC verification. This means either:
- The
STRIPE_WEBHOOK_SECRETenv var does not match the secret for your Stripe webhook endpoint. - The request was not sent by Stripe (forged payload).
Stripe webhook returns 503
STRIPE_WEBHOOK_SECRET environment variable is not set. Set it and restart.
Stripe webhook succeeds but no key appears
Check:
- The event is
checkout.session.completed. - The Checkout Session has a subscription ID.
- The Stripe Price ID exists in
stripe.price_to_gatewayinconfig.yaml. - The mapped gateway names exist in
workflows:orgateways:. workflow-api logsforstripe_price_unmappedorstripe_scope_invalid.
Key not revoked after subscription cancellation
Key revocation has a 48-hour grace period by default. The key will be revoked automatically after the grace window expires. Check workflow-api logs for cancellation_scheduled to confirm the pending revocation was recorded.
workflow-api init
workflow-api start
workflow-api status
workflow-api key create
workflow-api key create --name Pro --rate-limit 200
workflow-api key create --name Trial --rate-limit 20 --expires-in 30d
workflow-api key create --name Basic --rate-limit 30 --gateways my-workflow
workflow-api key list
workflow-api key revoke Pro
workflow-api logs
workflow-api logs --follow
workflow-api logs --level ERRORWithout the alias:
python3 cli.py status
python3 cli.py key listworkflow-api/
cli.py # CLI: start, keys, migrate, n8n commands
main.py # FastAPI app: routes, lifespan, webhook handler
config.yaml # Master config (gitignored — use config.example.yaml)
core/
auth.py # Key hashing, validation, create/revoke
cancellation_scheduler.py # Persistent 48h grace period poller
email_sender.py # SMTP email delivery with HTML template
limiter.py # In-memory token bucket rate limiter
logger.py # Async log queue — never blocks the event loop
proxy.py # httpx async request forwarder
security.py # SSRF protection, IP extraction
store.py # KeyStore protocol + singleton factory
store_sqlite.py # SQLite backend (WAL mode, production)
store_yaml.py # YAML backend (file locking, dev)
stripe_webhooks.py # Stripe event processing + HMAC verification
templates/
dashboard.html # Admin dashboard (Chart.js)
logs/
usage.log # Structured JSON access log
nginx.conf # Drop-in nginx reverse proxy config
Dockerfile
requirements.txt
Workflow API includes the following security measures:
| Feature | Description |
|---|---|
| SHA-256 key hashing | API keys are hashed before storage. Raw keys are shown once at creation and cannot be recovered. |
| HMAC webhook verification | Every Stripe webhook is verified via Stripe-Signature header before any business logic runs. Failures return 401. |
| Env-only secrets | The Stripe webhook secret (STRIPE_WEBHOOK_SECRET) is loaded exclusively from environment variables, never from config files. |
| Scrubbed error responses | Internal errors on the webhook endpoint return generic messages. Exception details are logged internally only. |
| SSRF protection | Target workflow URLs are validated at startup. Requests to localhost, private IPs, and cloud metadata endpoints are blocked. |
| Constant-time admin auth | Admin key comparison uses secrets.compare_digest to prevent timing attacks. |
| Persistent grace period | Pending cancellations are stored in the database (not memory), surviving restarts and working across multiple workers. |
| Gitignore coverage | config.yaml, database files, state files (pending_cancellations.json, resend_cooldown.json), and logs are all gitignored. |
MIT