Skip to content

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 ​

  1. WP Admin → Plugins → Add New → search for PackEdge Portal → Install Now → Activate. It is also on WordPress.org.
  2. WP Admin → Settings → PackEdge Portal → Connect PackEdge.
  3. Sign in to PackEdge if asked, pick the organization, review what the site will be able to do, and click Connect site.
  4. 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_BASE in wp-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.uninstalled with 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 one product.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 ​

TabSource endpointNotes
LicensesGET /v1/portal/licensesProduct, key, status, sites used / seats, expiry
DownloadsGET /v1/portal/downloadsLatest release per product with a 24-hour signed download URL
InvoicesGET /v1/portal/invoicesDate, amount, status; PDF link when the provider supplied one
SubscriptionsGET /v1/portal/subscriptionsPlan, 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_KEY salt) in wp_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.php with a wp_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_xxxxxxxxxxxx

The 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.com

A 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.com
json
{
  "data": {
    "email": "customer@example.com",
    "activeLicenses": 2,
    "totalLicenses": 3,
    "activeSubscriptions": 1
  }
}

Licenses ​

http
GET /v1/portal/licenses?email=customer@example.com
json
{
  "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.com
json
{
  "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.com
json
{
  "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.com
json
{
  "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.com
json
{
  "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.