Appearance
Customer Portal
White-label PackEdge on your own platform. Let customers view their licenses, downloads, invoices, and subscriptions — all under your brand.
┌─────────────────────────────────────────────────────────────────┐
│ yoursite.com/account (Your WordPress Site) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐ │ │
│ │ │ Licenses│ │Downloads│ │ Invoices│ │Subscriptions│ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────────┘ │ │
│ │ │ │
│ │ Customer sees YOUR branding, YOUR domain │ │
│ │ Data fetched server-side via the site's connection │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘The portal runs on WordPress through the PackEdge Portal plugin. The site connects to your PackEdge organization with one click; there are no API keys to copy.
WordPress Plugin
The PackEdge Customer Portal plugin gives you a drop-in [packedge_portal] shortcode. It proxies all API calls server-side, so the site's credential never reaches the browser.
Installation
- WP Admin → Plugins → Add New → search for PackEdge Portal → Install Now → Activate. It is also on WordPress.org.
- WP Admin → Settings → PackEdge Portal → Connect PackEdge.
- Sign in to PackEdge if asked, pick the organization, review what the site will be able to do, and click Connect site.
- You land back on the settings page with a success message. The Settings card (portal page, shortcode, diagnostic data sharing) appears only once the site is connected. Pick the page that hosts the portal and save.
Only organization owners and admins can approve a connection. The site must use HTTPS. Plain HTTP is accepted only for local development hosts (localhost, loopback IPs, *.local, *.test, *.localhost), so a LocalWP or similar site can connect too; those names never resolve to a public site.
Pointing at a non-production API? Define
PACKEDGE_PORTAL_API_BASEinwp-config.php(e.g.define( 'PACKEDGE_PORTAL_API_BASE', 'https://staging.api.packedge.dev' );). End users never see this option.
Connection
Once connected, the settings page shows the organization, who connected the site and when, and a live status check (Check connection round-trips to PackEdge and reports the response time). The credential itself is never displayed.
- Disconnect in the plugin revokes the connection on PackEdge and forgets it locally.
- console.packedge.dev → Settings → Customer Portal → Connected sites lists every connected site with when it was last active. Revoke there stops the site's portal on its next request; the plugin then shows Reconnect PackEdge.
- Connecting the same site again replaces its previous connection.
Diagnostics and deactivation feedback
The plugin sends no usage data unless a site admin opts in. It asks once, with a notice on Plugins and on its settings page (Allow / No thanks); the Diagnostic data sharing toggle in Settings changes the answer later.
- Opted in: opting in, activating and deactivating send
product.installed/product.uninstalledwith site URL, site title, admin email, WordPress / PHP / MySQL versions, server software, locale, timezone, multisite, memory limit, debug mode, active theme and active plugins. Opting out sends oneproduct.uninstalled. - Deactivation modal on Plugins: Skip sends nothing unless the admin opted in. Submit sends the chosen reason, the comment, the plugin version and the site URL, plus diagnostics only when opted in. The modal states which applies.
- Updates come from WordPress.org; the plugin has no update server of its own.
Shortcode
Add the portal to any page or post:
[packedge_portal]Visitors sign in one of two ways:
- Logged-in WordPress users see the portal for their account email, with no extra step.
- Everyone else enters their purchase email and gets a one-time sign-in link; the plugin then keeps an HttpOnly session cookie.
If anyone can register on your site without confirming their email address (for example, WooCommerce registration where the customer chooses their own password), turn off Sign in logged-in WordPress users in Settings → PackEdge Portal. Otherwise someone could register with a customer's email and see that customer's portal. With it off, logged-in users confirm their email with a sign-in link like everyone else. Developers can also use the packedge_portal_trust_wp_users filter.
Admin Preview
Site admins (anyone with manage_options) can preview another customer's portal by passing an email attribute. This is useful for support:
[packedge_portal email="customer@example.com"]The email attribute is ignored for non-admins, so it's safe to leave on a public page.
Tabs
| Tab | Source endpoint | Notes |
|---|---|---|
| Licenses | GET /v1/portal/licenses | Product, key, status, sites used / seats, expiry |
| Downloads | GET /v1/portal/downloads | Latest release per product with a 24-hour signed download URL |
| Invoices | GET /v1/portal/invoices | Date, amount, status; PDF link when the provider supplied one |
| Subscriptions | GET /v1/portal/subscriptions | Plan, status, renewal date, in-place Cancel button |
The Cancel button calls POST /v1/portal/subscriptions/{id}/cancel, which flags cancel_at_period_end = true. The customer keeps access until period end; your provider webhook (Stripe or Polar) syncs the final state when the cancellation actually fires.
Styling
The plugin ships minimal styles under the .packedge-portal and .pep-* class namespace. Override them from your theme:
css
.packedge-portal { /* container */ }
.pep-header { /* "My Account" header + email */ }
.pep-tabs { /* tab row */ }
.pep-tab { /* individual tab button */ }
.pep-tab.is-active { /* active tab */ }
.pep-panel { /* tab content container */ }
.pep-table { /* data table */ }
.pep-badge { /* status pill */ }
.pep-btn { /* primary button (Download, Cancel) */ }
.pep-btn--danger { /* destructive button (Cancel) */ }
.pep-empty { /* "Nothing here yet." */ }
.pep-loading { /* loading state */ }
.pep-error { /* error state */ }Security model
- Connecting uses OAuth 2.1 (authorization code + PKCE). The code is exchanged server-to-server, so the site's token never passes through the browser.
- The token is scoped to the customer portal only: it is rejected by every other Developer API route, so a compromised site cannot reach your products, licenses or settings.
- It is stored encrypted (AES-256-GCM, keyed from the site's
AUTH_KEYsalt) inwp_options.packedge_portal_connection, never autoloaded and never sent to the browser. Changing the site's salts invalidates it; reconnect afterwards. - All requests go through
admin-ajax.phpwith awp_create_nonce('packedge_portal')nonce. - The customer email is resolved server-side from the logged-in WP user (when trusted) or the magic-link session — clients cannot impersonate another customer by spoofing the request body.
- Each PackEdge query is scoped by the connected organization and the customer email, so one WordPress site can only ever see its own organization's customers.
Portal API reference
These are the endpoints the plugin calls. They accept only a WordPress site's connection token (ppc_…, minted by the connect flow above), never a developer API key, so they can't be called from custom platforms or from the browser.
Authorization: Bearer ppc_xxxxxxxxxxxxThe email belongs in the query string, and the plugin verifies the requesting user owns it before calling:
GET https://api.packedge.dev/v1/portal/licenses?email=customer@example.comA 401 whose response carries X-Packedge-Connection: revoked means the site's connection itself was rejected; other 401s are customer-session failures.
Connection
GET /v1/portal/connection # organization, who approved it, when; the plugin's health check
DELETE /v1/portal/connection # revoke this site's connection (the plugin's Disconnect)Endpoints
GET /v1/portal/overview ?email=<customer-email>
GET /v1/portal/licenses ?email=<customer-email>
GET /v1/portal/downloads ?email=<customer-email>
GET /v1/portal/invoices ?email=<customer-email>
GET /v1/portal/subscriptions ?email=<customer-email>
POST /v1/portal/subscriptions/{id}/cancel ?email=<customer-email>All responses use the { "data": ... } envelope used by the rest of the Developer API.
Overview
http
GET /v1/portal/overview?email=customer@example.comjson
{
"data": {
"email": "customer@example.com",
"activeLicenses": 2,
"totalLicenses": 3,
"activeSubscriptions": 1
}
}Licenses
http
GET /v1/portal/licenses?email=customer@example.comjson
{
"data": [
{
"id": "lic_xxx",
"licenseKey": "SHEET-XXXX-XXXX-XXXX",
"status": "active",
"seats": 5,
"siteCount": 2,
"expiresAt": "2027-01-01T00:00:00Z",
"createdAt": "2026-01-01T00:00:00Z",
"productName": "Sheetable Pro",
"productSlug": "sheetable-pro"
}
]
}Downloads
Latest release per product the customer has an active license for, with a short-lived signed URL.
http
GET /v1/portal/downloads?email=customer@example.comjson
{
"data": [
{
"productName": "Sheetable Pro",
"productSlug": "sheetable-pro",
"releaseId": "rel_xxx",
"version": "1.0.0",
"fileName": "sheetable-pro-1.0.0.zip",
"fileSize": 71680,
"releaseNotes": "Initial release",
"releasedAt": "2026-05-30T00:00:00Z",
"downloadUrl": "https://api.packedge.dev/dl/rel_xxx?exp=...&sig=..."
}
]
}The downloadUrl is valid for 24 hours. Re-fetch this endpoint to mint a fresh one.
Invoices
http
GET /v1/portal/invoices?email=customer@example.comjson
{
"data": [
{
"id": "pay_xxx",
"amount": 49.00,
"currency": "USD",
"status": "completed",
"paymentMethod": "stripe",
"invoicePdfUrl": "https://...",
"productName": "Sheetable Pro",
"createdAt": "2026-05-01T00:00:00Z"
}
]
}invoicePdfUrl is null unless the provider (Stripe / Polar) supplied a hosted invoice.
Subscriptions
http
GET /v1/portal/subscriptions?email=customer@example.comjson
{
"data": [
{
"id": "sub_xxx",
"status": "active",
"currentPeriodStart": "2026-05-01T00:00:00Z",
"currentPeriodEnd": "2027-05-01T00:00:00Z",
"cancelAtPeriodEnd": false,
"canceledAt": null,
"planName": "Pro Yearly",
"billingCycle": "yearly",
"price": 49.00,
"currency": "USD",
"productName": "Sheetable Pro"
}
]
}Cancel Subscription
http
POST /v1/portal/subscriptions/sub_xxx/cancel?email=customer@example.comjson
{
"data": {
"id": "sub_xxx",
"cancelAtPeriodEnd": true
}
}This flags the subscription locally. The provider webhook syncs the final canceled state once the period ends and the provider stops billing.
