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 from PackEdge via API key     │    │
│  └─────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘

Two integration options:

MethodBest ForEffort
WordPress PluginWordPress sites5 min
REST APICustom platforms (Laravel, Next.js, etc.)Custom

WordPress Plugin

The PackEdge Customer Portal plugin gives you a drop-in [packedge_portal] shortcode. It proxies all API calls server-side, so your developer API key never reaches the browser.

Installation

  1. Download packedge-portal-1.0.0.zip.
  2. WP Admin → Plugins → Add New → Upload Plugin → choose the zip → Install Now → Activate.
  3. Generate a developer API key at console.packedge.dev → Settings → API Keys.
  4. WP Admin → Settings → PackEdge Portal.
  5. Paste the API key and save.

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.

Shortcode

Add the portal to any page or post:

[packedge_portal]

Visitors must be logged in to WordPress — the plugin resolves the current user's email and queries PackEdge for that customer.

If the customer isn't logged in, the shortcode renders a "Please log in" link to wp-login.php instead.

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

  • The API key is the only stored option (wp_options.packedge_portal_api_key) and never sent to the browser.
  • 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 — clients cannot impersonate another customer by spoofing the request body.
  • Each PackEdge query is scoped by the API key's developer ID and the customer email, so one WordPress site can only ever see its own developer account's customers.

REST API Integration

If you're not on WordPress, hit the portal endpoints directly. They power the plugin above.

Authentication

Every portal request needs your developer API key. Never call these endpoints from the browser — your API key would leak to anyone viewing source. Always proxy through your server.

Authorization: Bearer pk_live_xxxxxxxxxxxx

The email belongs in the query string:

GET https://api.packedge.dev/v1/portal/licenses?email=customer@example.com

Your server must verify the requesting user actually owns email (via your own session). PackEdge trusts whatever email you pass.

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.


Custom Integration Example

A minimal Laravel controller that mirrors the plugin's behavior:

php
class PortalController extends Controller
{
    public function licenses(Request $request)
    {
        $user = $request->user();
        $resp = Http::withToken(config('packedge.api_key'))
            ->get('https://api.packedge.dev/v1/portal/licenses', [
                'email' => $user->email,
            ]);

        return response()->json($resp->json('data'));
    }
}

Same idea in Next.js:

ts
// app/api/portal/licenses/route.ts
export async function GET(req: Request) {
  const session = await getServerSession();
  if (!session) return new Response('Unauthorized', { status: 401 });

  const r = await fetch(
    `https://api.packedge.dev/v1/portal/licenses?email=${encodeURIComponent(session.user.email)}`,
    { headers: { Authorization: `Bearer ${process.env.PACKEDGE_API_KEY}` } },
  );
  const json = await r.json();
  return Response.json(json.data);
}

The pattern is always the same: your server authenticates the user, looks up their email, and proxies the portal call. PackEdge handles the rest.