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 from PackEdge via API key │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘Two integration options:
| Method | Best For | Effort |
|---|---|---|
| WordPress Plugin | WordPress sites | 5 min |
| REST API | Custom 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
- Download packedge-portal-1.0.0.zip.
- WP Admin → Plugins → Add New → Upload Plugin → choose the zip → Install Now → Activate.
- Generate a developer API key at console.packedge.dev → Settings → API Keys.
- WP Admin → Settings → PackEdge Portal.
- Paste the API key and save.
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.
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
| 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
- 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.phpwith awp_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_xxxxxxxxxxxxThe email belongs in the query string:
GET https://api.packedge.dev/v1/portal/licenses?email=customer@example.comYour 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.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.
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.
