Bodholdt Licensing Docs
Set up your self-hosted licensing system from scratch. Full integration guide included.
What Bodholdt Licensing is
Bodholdt Licensing turns your own WordPress site into a complete store and licensing server for the plugins and themes you sell. Customers buy through Stripe, get a license key by branded email, download your software, and manage their own subscriptions — all on your site, with no third-party SaaS in the middle and no WooCommerce required. Each piece of software you ship phones home to your server to check its license and pull updates. This page is written for two audiences: shop owners who just want to get selling, and developers who need the exact API, database, and SDK details — both are covered.
Quick start (about 10 minutes)
- Install & activate. Upload
bodholdt-licensing.zipvia Plugins → Add New → Upload Plugin, then activate. - Follow the Setup Wizard. Go to Bodholdt Licensing → Dashboard and the wizard walks you through each step below.
- Enter your license key. Paste the key from your purchase email. You have a 7-day grace period before a key is required.
- Connect Stripe. Enter your Stripe Secret Key (
sk_live_...) and Webhook Secret (whsec_...). - Add your products. Go to Settings → Products and register each plugin or theme you want to sell.
- Generate your pages. Click Generate Pages to auto-create your Shop, Success, and Portal pages.
- Upload your zip files. Use the upload button in the Product Manager, or drop
.zipfiles into the vault directory shown in Settings. - Generate your SDK. Go to SDK Generator, enter your plugin details, and copy the generated
licensing.phpfile into your plugin.
That’s it. Your customers can now buy licenses, receive keys by branded HTML email, download your software, and manage their subscriptions — all from your own site.
What you need first
A few things have to be in place before the plugin can do its job:
| Requirement | Minimum |
|---|---|
| WordPress | 6.0 or higher |
| PHP | 8.0 or higher |
| SSL | HTTPS required |
| Stripe account | stripe.com |
| SMTP plugin | Recommended (WP Mail SMTP, FluentSMTP, etc.) |
| Server | Apache or Nginx (Nginx needs a manual vault deny rule — see Downloads) |
The plugin checks whether an SMTP plugin is active and shows a warning on the Dashboard if your email delivery might be unreliable. If you see that warning, install one of the SMTP plugins above so your customers actually receive their license keys.
Connecting Stripe
Stripe handles the money. You give the plugin two things from Stripe — a Secret Key (so it can create checkouts) and a Webhook Secret (so Stripe can tell your site when a purchase happens).
Step 1: Add your API key
- Log in to your Stripe Dashboard.
- Go to Developers → API Keys.
- Copy your Secret Key (
sk_live_...) and paste it into Bodholdt Licensing → Settings → Stripe & License. - Click Verify to confirm the connection.
Step 2: Set up the webhook
A webhook is just a message Stripe sends to your site the moment something happens (a sale, a cancellation, a failed payment). Set it up once:
- In Stripe, go to Developers → Webhooks.
- Click Add Endpoint.
- Set the URL to exactly:
https://yoursite.com/?cls_webhook=stripe - Select these four events:
checkout.session.completed— processes new purchases and generates licensescustomer.subscription.updated— syncs cancellations, reactivations, and payment-status changescustomer.subscription.deleted— immediately expires licensesinvoice.payment_failed— logs the failure and notifies the admin
- Copy the Signing Secret (
whsec_...) and paste it into your Settings.
You don’t have to worry about duplicate sales: webhook processing has idempotency protection (transient-based locking prevents the same purchase from creating two licenses) and Stripe signature verification on every event.
Adding your products
Each plugin or theme you sell is a product. Set them up under Settings → Products.
The fields you fill in
| Field | What it’s for |
|---|---|
| Slug | Unique identifier (e.g. my-plugin). Must match the item_reference in your SDK code. |
| Name | Display name shown to customers at checkout. |
| Filename | Exact name of the .zip file in your vault (e.g. my-plugin.zip). |
| Monthly / Yearly / Lifetime | Price amount and Stripe Price ID (price_xxx) for each billing cycle. Leave blank to disable a cycle. |
| Trial Days | Number of free-trial days (0 to disable). Limited to one trial per product per email. |
| Max Domains | Maximum sites a single license key can activate on (default: 1). |
Getting your Stripe Price IDs
Each price you charge lives in Stripe and has its own ID. To create one:
- In Stripe, go to Products → Add Product.
- Add a Recurring price for monthly/yearly, or a One-time price for lifetime.
- Copy the Price ID (
price_1Abc...) into the matching field in the Product Manager. - Click the Validate button next to each Price ID field to check it against Stripe.
Releasing updates (the auto-updater)
When you ship a new version, your customers can get it through the normal WordPress update screen. To push an update, set the version metadata under Settings → Products → Product Versions:
- Upload the new
.zipto your vault (same filename, or update the filename in Product Manager). - Update the Version number in Product Versions.
- Optionally update the Changelog, Tested WP, Requires WP, and Requires PHP fields.
Every customer installation will see the update within 12 hours — or right away if they click Check Again under Dashboard → Updates.
Uploading your files
Upload .zip files (up to 100MB) straight from the Product Manager with the upload button. They’re stored in the vault directory set in Settings. On Apache, a .htaccess deny rule is created for you automatically. On Nginx you have to add the deny rule by hand — the Dashboard shows you the exact rule to paste when it detects Nginx (see Secure download system below).
The three customer pages (shortcodes)
Clicking Generate Pages in the Product Manager creates all three of these for you automatically. If you ever need to place one by hand, drop the shortcode onto a page. (Shown here as text so they don’t run.)
[cls_checkout_form]
This is your storefront. It renders a full shopping cart and checkout flow: customers pick products, choose a billing plan (monthly / yearly / lifetime / trial), see live price calculations, confirm their order, enter their email, and get sent to Stripe Checkout. It supports the ?buy=slug URL parameter to pre-select a product.
The form handles mixed carts (paid products plus free trials in one checkout), prevents mixing monthly and yearly subscriptions in the same cart (a Stripe limitation), and enforces one trial per product per email. If you haven’t configured any products, it shows “Store Coming Soon.” If your own plugin license is invalid, it shows “Store Temporarily Unavailable.”
[cls_success_page]
The thank-you page customers land on after paying. It reads the session_id query parameter to look up the completed checkout and shows:
- License keys for each purchased product
- Download buttons (submitted via POST so keys never appear in the URL or server logs)
- “What’s Next” instructions for using the SDK
- Links to the customer portal and shop
[cls_portal]
A self-service portal for your customers, with two features:
- Manage Billing — the customer enters their email and gets a link to the Stripe Billing Portal, where they can update payment methods, view invoices, and cancel subscriptions.
- My Keys — the customer enters their email, receives a 6-digit verification code, then sees all their active license keys with download buttons. Verification codes expire after 10 minutes.
No WordPress user accounts are needed — everything runs on email verification.
The SDK Generator (developer reference)
Plain version: this builds the little file you drop into each plugin so it can talk to your licensing server. No hand-coding required. The details below are precise so developers can rely on them.
How to use it
- Go to Bodholdt Licensing → SDK Generator.
- Enter your Plugin Name and a unique Prefix (letters/numbers only).
- Click Generate SDK.
- Copy the generated code and save it as
licensing.phpin your plugin directory. - Add this to your main plugin file:
require_once __DIR__ . '/licensing.php';
What the SDK includes
- License verification — checks the key against your server every 12 hours.
- Domain activation / deactivation — registers and releases domain slots with your license server.
- Auto-updater — hooks into
pre_set_site_transient_update_pluginsso customers see native WordPress update notifications. Downloads are authenticated via HTTP header (the license key never appears in a URL or server log). - Admin license page — a styled settings page where customers enter and manage their license key (activate, deactivate, re-verify).
- Feature gating — use
YourPrefix_Licensing::is_licensed()anywhere in your code to gate premium features. - Admin notice — displays a dismissible notice when the plugin is unlicensed.
- Plugin info modal — provides changelog, requirements, and author info in the WordPress “View Details” popup.
Managing licenses
Every license you’ve issued lives under Bodholdt Licensing → Manage Licenses. From there you can do everything by hand if you ever need to.
Things you can do to a license
| Action | What it does |
|---|---|
| Issue New License | Manually create a license for any product and email address. |
| Quick Create | Create a license straight from the Dashboard with email, product, and optional expiry. |
| Revoke | Immediately block a license. The customer loses access. |
| Reactivate | Restore a revoked license to active status. |
| Delete | Permanently remove a license record. |
| Sync | Pull the latest subscription status from Stripe for a specific license. |
| Bulk Actions | Revoke, reactivate, or delete multiple licenses at once (uses database transactions for atomicity). |
| Send Email | Resend the license-key email to the customer. |
Editing in place
- Domains — click the × next to any registered domain to remove it from a license.
- Expiry — click the expiry date to edit it inline with a date picker.
Finding and exporting
Search by email, license key, or product. Filter by status (Active, Blocked, Expired). Choose how many show per page (10, 25, 50, 100). Export all licenses as CSV.
The Dashboard & Setup Wizard
The Dashboard (Bodholdt Licensing → Dashboard) is your home base. It gives you:
- Metrics cards — total licenses, active, expired, trials, and products (cached with a 5-minute transient).
- Setup checklist — tracks completion of your plugin license, Stripe keys, webhook, storage path, products, pages, and email delivery.
- Setup wizard — a multi-step walkthrough for license key entry, Stripe configuration, and page generation.
- Recent activity — the latest license events from the activity log.
- Quick create — issue a new license without leaving the Dashboard.
- Quick links — jump to common destinations (checkout page, portal, Stripe Dashboard).
The Gatekeeper (your own license protection)
The Gatekeeper protects your Bodholdt Licensing installation — it makes sure your own license with Bodholdt Labs is active. Here’s how it behaves, in plain terms:
- Grace period: you have 7 days after activation to enter your license key.
- Daily verification: the plugin phones home to
bodholdtlabs.comonce a day to verify your license. - Graceful degradation: if your server can’t reach the home server, your license status is not changed. A 30-day stale check makes sure prolonged network blocks or firewall rules eventually trigger re-verification.
- Soft lockout: if your license expires, creating new licenses and running new checkouts is disabled. Existing customer licenses, downloads, activations, and subscription management all keep working normally.
The important part: your customers are never affected by your license status.
Activity Log
Every license event is recorded under Bodholdt Licensing → Activity Log — a running history you can search and export.
What gets tracked: created, activated, deactivated, revoked, reactivated, deleted, payment failed, reminder sent, email failed.
- Filter by action type and search by key, details, or IP address.
- IP tracking with Cloudflare-aware detection (
HTTP_CF_CONNECTING_IP). - Automatic daily rotation purges entries older than the configured retention period (default: 365 days, minimum: 30).
- Manual purge: choose 7, 30, 60, 90, 180, or 365 days.
- CSV export — download up to 10,000 entries as CSV.
- GDPR/CCPA export — export all license and activity data for a specific customer email as JSON.
Emails to your customers
Bodholdt Licensing sends professional, branded HTML emails — a gradient header, a white content area, and a branded footer. You can customize the templates under Settings → Emails. Use Send Test Email in that tab to preview how your templates render before going live.
When emails go out
| When it’s sent | Customizable? | |
|---|---|---|
| Trial License | After a free-trial checkout (no payment) | Yes — subject and body template |
| Purchase License | After a successful Stripe payment (webhook) | Yes — subject and body template |
| Expiry Reminder | At 30, 7, and 4 days before expiration (cron) | No — auto-generated |
| Trial Upgrade | 4 days before trial expiration (cron) | No — includes upgrade CTA |
| Payment Failed | On the invoice.payment_failed webhook |
No — admin notification |
| Verification Code | Customer requests a portal key lookup | No — 6-digit code, 10-min expiry |
| Billing Portal Link | Customer requests billing-portal access | No — contains the Stripe Portal URL |
| Manual License Email | Admin clicks “Send to Customer” | No — auto-generated |
Merge tags
Drop these into the Trial and Purchase email templates and the plugin fills them in:
| Tag | Replaced with |
|---|---|
{licenses} |
Formatted license keys with product names, expiry dates, and download buttons |
{email} |
The customer’s email address |
{manage_url} |
Link to the customer portal page |
Secure download system
Plain version: customers can only download your files with a valid, active license key, and the files themselves can never be reached directly by URL. The mechanics below are exact for developers and for setting up Nginx.
Downloads are handled by a priority-1 init hook that runs before any other WordPress processing. The flow:
- The customer submits a download request with their license key (via POST form, GET parameter, or the
X-License-KeyHTTP header). - The plugin validates the key: it exists, its status is active/valid, and it’s not expired.
- Rate-limit check: 10 downloads per minute per IP.
- The file is streamed in 8KB chunks with output buffering and gzip disabled for clean delivery.
- Connection status is checked after each chunk — streaming stops if the client disconnects.
Vault configuration
- Preferred location: outside the web root (e.g.
/bodholdt_product_vault/one level abovepublic_html). The plugin creates this automatically. - Fallback location: inside
wp-content/uploads/with a randomized directory name. - Apache: a
.htaccessfile withDeny from allis generated automatically on activation. - Nginx: add a deny rule manually. The Dashboard shows the exact rule when it detects Nginx:
location ~* /wp-content/uploads/plugin_products/ { deny all; }
The auto-updater in the generated SDK uses the X-License-Key HTTP header to authenticate downloads, so license keys never appear in URLs or server access logs.
Moving settings between sites
If you run a staging and a production site, you can copy your settings between them under Settings → Advanced.
- Export: downloads a JSON file with all product configs, email templates, page slugs, currency, and brand settings.
- Import: upload a JSON file to restore those settings.
- Security: Stripe API keys, webhook secrets, operating mode, and license keys are never exported or imported. The import uses an allowlist to block injection of sensitive values. Enter credentials manually on each server.
Advanced settings
Found under Settings → Advanced:
| Setting | What it does |
|---|---|
| Brand Name | Replace “Bodholdt Licensing” with your own brand name throughout the admin and emails. |
| Checkout Button Text | Customize the “Add Product” button label on the checkout form. |
| Payment Failure Notifications | Turn admin email alerts on or off when a subscription payment fails. |
| Preserve Data on Delete | When enabled, uninstalling the plugin keeps all database tables, options, and transients intact. |
API Reference (developer reference)
This section is for developers integrating with the licensing server directly. The generated SDK uses these endpoints under the hood; the values below are exact.
Query-string API (legacy)
Used by the SDK for license verification and auto-updates. No authentication required — the license key acts as the credential. All endpoints are rate-limited (20/min per IP).
| Endpoint | Parameters | Response |
|---|---|---|
?cls_action=cls_check |
license_key, item_reference, registered_domain |
License status, expiry, domain count |
?cls_action=cls_activate |
license_key, item_reference, registered_domain |
Registers the domain against the license |
?cls_action=cls_deactivate |
license_key, item_reference, registered_domain |
Releases the domain slot |
?cls_action=update_check |
item_reference |
Latest version, changelog, requirements (5-min cache) |
?cls_action=download |
license_key (GET, POST, or X-License-Key header) |
Streams the zip file |
REST API
Full REST API at /wp-json/bodholdt-licensing/v1/. Admin endpoints require authentication with the manage_options capability. Public endpoints are rate-limited (30/min per IP).
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /licenses |
Admin | List all licenses (paginated). Args: page, per_page, search, status |
| POST | /licenses |
Admin | Create a license. Args: product, email, expiry, is_trial |
| GET | /licenses/{key} |
Public | Get license details. Public: minimal data. Admin: full data (email, domains, txn_id) |
| POST | /licenses/{key}/activate |
Public | Activate license on a domain. Args: domain |
| POST | /licenses/{key}/deactivate |
Public | Deactivate license from a domain. Args: domain |
| GET | /products |
Public | List all products with pricing info |
| GET | /update-check/{slug} |
Public | Check for plugin updates by product slug |
Rate limiting
All public-facing endpoints are rate-limited using WordPress transients with per-IP tracking. The plugin trusts HTTP_CF_CONNECTING_IP (Cloudflare) but intentionally does not trust X-Forwarded-For (client-spoofable).
| Context | Limit |
|---|---|
| File downloads | 10/min per IP |
| Query-string API (per action) | 20/min per IP |
| REST API (general) | 30/min per IP |
| REST activate/deactivate | 20/min per IP |
| Portal billing request | 3/min per IP |
| Lookup code send | 3/min per IP |
| Lookup code verify | 5/min per IP |
Security
Bodholdt Licensing has been through a 9-phase commercial-grade security audit scoring 10/10, with 70+ fixes applied. Security measures include:
- 128-bit license keys generated with
random_bytes(). - All API endpoints are rate-limited to prevent brute-force attacks.
- Stripe webhook signature verification on every event.
- CSRF protection (nonce verification) on all admin forms and AJAX handlers.
- Prepared SQL statements on every database query.
- XSS protection — all output escaped with
esc_html(),esc_attr(),esc_url(). - Input sanitization — all
$_GET,$_POST, and$_SERVERvalues sanitized. - Email verification for license-key lookups (6-digit code with timing-safe comparison via
hash_equals()). - Vault protection — download files are never directly accessible via URL.
- HTTP header authentication — the auto-updater passes the license key via the
X-License-Keyheader instead of the URL. - No serialized PHP data — all structured data stored as JSON.
- Idempotent webhook processing — transient locking prevents duplicate license creation.
- Path traversal prevention —
basename()on all download filenames. - Settings import restricted to an allowlist of safe keys (no credential or mode injection).
- WordPress Coding Standards —
wp_date(),wp_json_encode(),sanitize_callbackon all registered settings.
Database tables (developer reference)
The plugin creates two custom tables per site (using dbDelta()).
{prefix}cls_licenses
| Column | Type | Description |
|---|---|---|
id |
mediumint AUTO_INCREMENT | Primary key |
license_key |
varchar(255) UNIQUE | 128-bit hex key (32 chars) |
email |
varchar(255) INDEXED | Customer email |
item_reference |
varchar(255) | Product slug |
date_created |
date | License creation date |
date_expiry |
date NULL | Expiration date (NULL = lifetime) |
status |
varchar(20) INDEXED | active, valid, blocked, expired |
max_allowed_domains |
tinyint DEFAULT 1 | Domain activation limit |
registered_domains |
text (JSON) | Array of activated domains |
txn_id |
varchar(255) INDEXED | Stripe subscription/payment ID |
{prefix}cls_activity_log
| Column | Type | Description |
|---|---|---|
id |
bigint AUTO_INCREMENT | Primary key |
license_id |
mediumint INDEXED | Foreign key to cls_licenses |
license_key |
varchar(255) | License key at time of event |
action |
varchar(50) INDEXED | Event type |
details |
text | Event-specific details |
ip_address |
varchar(45) | Client IP (Cloudflare-aware) |
created_at |
datetime INDEXED | Event timestamp |
WP-Cron events (developer reference)
Two scheduled jobs run in the background.
| Hook | Schedule | Description |
|---|---|---|
cls_daily_log_rotation |
Daily | Deletes activity-log entries older than the configured retention (default 365 days) |
cls_daily_expiry_reminders |
Daily | Sends reminder emails at 30, 7, and 4 days before license expiry |
Both events are scheduled on activation and cleared on deactivation.
What happens when you uninstall
When the plugin is deactivated, it clears transients, rate-limiter data, and cron events. All your license data and settings are preserved.
When the plugin is deleted (via Plugins → Delete), it removes:
- All
cls_*options from the options table - Both database tables (
cls_licensesandcls_activity_log) - All
cls_*transients - On Multisite: it repeats this cleanup for every site in the network
To keep your data, turn on “Preserve data on delete” in Settings → Advanced before removing the plugin.
Troubleshooting
“Store Temporarily Unavailable” on checkout
Your own Bodholdt Licensing license has expired or is missing. Go to Settings → Stripe & License and enter a valid license key. If you think this is a mistake, check that your server can reach bodholdtlabs.com (the phone-home check runs daily, with a 30-day stale threshold).
Webhook events not being received
- Verify your webhook URL is exactly:
https://yoursite.com/?cls_webhook=stripe - Confirm all four events are selected in Stripe.
- Check that the Webhook Secret matches what’s in your Settings.
- Use the Test Webhook button in Settings to confirm your URL is reachable.
- In Stripe, go to Webhooks → your endpoint → Recent Deliveries to see error details.
Customers can’t download after purchase
- Make sure the
.zipfilename in Product Manager matches the actual file in your vault directory. - Verify the vault directory is readable by your web-server user.
- Check that the customer’s license status is
activeorvalidin Manage Licenses. - On Nginx, make sure the deny rule blocks direct access but doesn’t interfere with PHP processing.
Auto-updater not showing updates
- Make sure Product Versions are set and the version number is higher than what the customer has installed.
- Customer installations cache the update check for 12 hours. They can go to Dashboard → Updates and click Check Again.
- Verify the customer’s license key is active and the domain is registered.
License verification fails in a customer’s plugin
- Confirm the Server URL in the SDK matches your site URL exactly (including
https://). - Verify the Item Reference matches the product slug in your Product Manager.
- Test the API directly:
https://yoursite.com/?cls_action=cls_check&license_key=XXXX&item_reference=slug®istered_domain=example.com
Emails not being delivered
- Install an SMTP plugin (WP Mail SMTP, FluentSMTP, etc.). The Dashboard shows a warning if none is detected.
- Use the Send Test Email button in Settings → Emails to verify delivery.
- Check the Activity Log for
email_failedevents.
Frequently asked questions
What happens to my customers if my license expires?
Nothing. Your customers’ licenses, downloads, subscriptions, and auto-updates all keep working. The only thing that stops is your ability to create new licenses and process new checkouts.
Can I use this on multiple sites?
Each Bodholdt Licensing license is valid for one domain. If you run separate licensing servers (e.g. staging and production), you’ll need a license for each.
Does this work with Multisite?
Yes. The plugin supports network-wide activation and creates its database tables for each site in the network.
Do I need WooCommerce?
No. Bodholdt Licensing handles payments directly through Stripe. No WooCommerce, Easy Digital Downloads, or any other ecommerce plugin is needed.
Can I sell themes too, or just plugins?
Both. Any WordPress software you can package as a .zip file can be sold through Bodholdt Licensing — plugins, themes, or any other extension.
Can customers have multiple licenses for the same product?
Yes. Paid products (subscriptions and one-time purchases) can be bought multiple times by the same email, so customers can license your plugin on multiple sites. Free trials are limited to one per product per email.
Support & lifetime credits
Need a hand? Open a ticket on our Support page using the email you bought with. If you’re on an annual or monthly plan, support is included while your plan is active. If you bought a lifetime license, your purchase includes support credits that never expire — 15 for a single site, 50 for 5 sites, and 150 for 25 sites — applied automatically at checkout and added to any credits you already have. One credit covers one support request; pre-sales questions, feature requests, bug reports, and billing questions are always free. The documentation home has the full support policy.