Skip to content

Pak Key Lifecycle

peruvian edited this page May 28, 2026 · 1 revision

Pak Key Lifecycle

A pak_<32 hex> is the customer-facing credential you mint on behalf of an end-user. It has its own state machine separate from your billing system, and getting the lifecycle right is what separates a kit deployment that runs itself from one that paginates support tickets.

This page covers all six lifecycle states: mint → in-use → top-up → rotate → suspend → revoke.


Mint

You call:

const key = await proxies.poolKeys.create({
  label: 'customer:alice@example.com',
  trafficCapGB: 10,
  expiresAt: new Date(Date.now() + 60 * 86_400_000).toISOString(),
  idempotencyKey: stripeEvent.id,
});

You get back:

{
  id:            'pakkey_xxx',          // stable, use this for lookups
  key:           'pak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
                                        // the SECRET — shown once, never again
  label:         'customer:alice@example.com',
  trafficCapGB:  10,
  trafficUsedMB: 0,
  expiresAt:     '2026-07-27T00:00:00.000Z',
  enabled:       true,
  isExpired:     false,
  createdAt:     '2026-05-28T...',
}

The key field is shown exactly once. Store it in your DB immediately — there is no API to retrieve it later (only audit-logged reveal for the masked form). If you lose it, your only option is regenerate() which mints a fresh secret.

Idempotency

The idempotencyKey is non-negotiable for production webhook handlers:

  • Stripe will retry your webhook on any 5xx. Without idempotency, every retry mints another pak.
  • The platform dedupes by idempotencyKey within a 24-hour window — same key in that window returns the same pak (with secret) from cache.
  • Bind the idempotency key to a stable domain object: Stripe event ID, your invoice ID, your purchase row ID. Never randomUUID() at retry time — that defeats the purpose.

After 24h, the dedupe expires. If you somehow process the same event after 24h (rare with most queues), you'll double-mint. Persist tx/event → pak mappings in your own DB if your event lag can exceed 24h.


In-use

The customer's HTTP/SOCKS5 client embeds the pak in the proxy URL:

http://psx_yourUsername-mbl-us-sid-jobX-rot-sticky:pak_xxx@gw.proxies.sx:7000

Every byte through that URL increments trafficUsedMB on the pak. Updates flush every ~5 seconds from the gateway to the central DB. There's no real-time read — trafficUsedMB is eventually consistent within ~10s.

Pre-flight rejection conditions (the gateway returns 407 E_AUTH_INVALID before any bytes flow):

  • enabled === false
  • isExpired === true (expiresAt passed)
  • Reseller's wholesale balance < 0 (your psx_ account empty)

Mid-flight enforcement (gateway cuts the connection):

  • trafficUsedMB / 1024 >= trafficCapGB (auto-suspend, see below)

Top-up

When the customer pays again, don't mint a new pak. Top up the existing one:

await proxies.poolKeys.topUp(key.id, {
  addTrafficGB: 10,
  extendDays:   30,
  idempotencyKey: stripeEvent.id,
});

This is a single atomic server-side write:

  • trafficCapGB += addTrafficGB
  • expiresAt = max(now, current_expiresAt) + extendDays

The max(now, ...) is important — if the pak already expired, top-up extends from now, not from the past expiry. Customer doesn't lose the days they bought.

Why not mint? Because:

  • Customer keeps the same pak_ string — no swap-credentials downtime
  • Their sid sticky sessions stay pinned to the same modems
  • Your billing system stays simple (one pak per customer, not N)
  • Auto-suspend (below) doesn't fire mid-payment-flow

If you're tempted to mint a parallel pak for "topping up," resist. Top-up is what the API is shaped for.


Rotate (regenerate the secret)

If a customer leaks their pak (committed to GitHub, in a screenshot, on Twitter), rotate immediately:

const refreshed = await proxies.poolKeys.regenerate(key.id, {
  idempotencyKey: `rotate-${key.id}-${Date.now()}`,
});
// refreshed.key is the NEW pak_ secret — old one is invalidated IMMEDIATELY

Old secret stops working mid-connection. Any TCP sessions using the old pak get an RST and a fresh 407 on retry. This is intentional and instant — no "5 minute grace period" for security.

Have the customer update their proxy URL with the new secret and reconnect. Their sid sticky pinning is preserved — they'll be reassigned to the same modem on the next request with the same sid.


Suspend (auto)

When trafficUsedMB / 1024 >= trafficCapGB, the platform automatically flips enabled = false and writes an audit log entry auto_suspended_cap_exceeded. This is a defense against leaked credentials being used to drain your wholesale balance.

Auto-suspend does NOT auto-recover — even after top-up. You must explicitly:

await proxies.poolKeys.update(key.id, { enabled: true });

This is intentional: if a pak got suspended because the cap was hit by an attacker, you don't want the next top-up to silently re-enable an already-compromised key. Have your top-up flow re-enable explicitly and audit-log it.

Also auto-suspends:

  • auto_suspended_expired — when expiresAt passes and the nightly cron runs (03:30 UTC tidy-up). The pak rejects traffic immediately at expiry regardless; this just flips the boolean for cleanliness.

Suspend (manual)

To pause a customer (failed payment, abuse investigation) without deleting:

await proxies.poolKeys.update(key.id, { enabled: false });

Their proxy URL starts returning E_AUTH_INVALID immediately. Their pak_ and trafficCapGB and history are preserved. Flip enabled: true to reactivate.


Revoke (delete)

await proxies.poolKeys.delete(key.id);

Pak is gone — secret invalidated, future requests rejected. You cannot undelete. If you delete by accident, you have to mint a new pak with a new secret and the customer must update their config.

Use delete for: customer churn (definitely won't come back), GDPR / data-deletion requests, decommissioning test paks.

Use enabled: false for: maybe-coming-back, billing disputes, suspicious-but-not-confirmed-abuse.


Audit log

Every mutation writes an audit log entry. The platform exposes:

// All audit entries across all your paks
const events = await proxies.poolKeys.audit({
  action: 'auto_suspended_cap_exceeded',  // optional filter
  limit:  100,
});

// Scoped to one pak
const events = await proxies.poolKeys.auditForKey(key.id, { limit: 100 });

Recorded actions:

  • create / update / topup / regenerate / delete
  • reveal (audit-logged unmask via API)
  • gateway_auth_success / gateway_auth_failure
  • auto_suspended_cap_exceeded / auto_suspended_expired

Each entry records the ip, userAgent, requestId, authMethod (apiKey for SDK calls, jwt for dashboard, gateway for byte-flow events). Use these for incident forensics.

Retention: 90 days in the platform DB. For long-term retention, stream them into your own SIEM via the webhook events (POST /v1/reseller/webhooks).


The full state machine

        [mint] ──────────────────►  enabled=true, isExpired=false, trafficUsed=0
           │                                              │
           │                                              │ (use)
           │                                              ▼
           │                            ┌───────  trafficUsed climbs  ──┐
           │                            │                                │
           │                            ▼                                ▼
           │              (cap reached) │                  (expiresAt passes)
           │            auto_suspended_ │                  auto_suspended_
           │            cap_exceeded    │                  expired
           │                            │                                │
           │                            ▼                                ▼
           │                       enabled=false ◄──────┐         enabled=false
           │                            │               │              │
           │              [topUp]       │               │              │
           │            cap +=, expiry +│ [update       │   [update    │
           │                            │  enabled:true]│  enabled:true]
           │                            │  (manual)     │  + extend?   │
           │                            ▼               │              │
           └─────────► enabled=true, larger cap ◄───────┘              │
                                                                        │
        [regenerate] ─────────────────────────► same pak_id, new secret │
                                                                        │
        [delete] ──────────────────────────────► gone, permanent ───────┘

Common patterns

Stripe webhook → mint

// Use the Stripe event id as the idempotency key.
const key = await proxies.poolKeys.create({
  label:          `customer:${customer.email}`,
  trafficCapGB:   plan.gb,
  expiresAt:      addDays(new Date(), plan.days).toISOString(),
  idempotencyKey: event.id,
});
// Store key.id + key.key in your DB. Don't fetch the secret again — you can't.

Stripe webhook → top-up (renewal)

const existingKeyId = await db.getPakIdForCustomer(customer.id);
await proxies.poolKeys.topUp(existingKeyId, {
  addTrafficGB:   plan.gb,
  extendDays:     plan.days,
  idempotencyKey: event.id,
});
// Re-enable if it auto-suspended.
await proxies.poolKeys.update(existingKeyId, { enabled: true });

Customer leaks their key on Twitter

const refreshed = await proxies.poolKeys.regenerate(existingKeyId, {
  idempotencyKey: `rotate-${existingKeyId}-${Date.now()}`,
});
await db.updatePakSecret(customer.id, refreshed.key);
await sendEmail(customer.email, 'Your proxy credentials were rotated…');

Reading remaining balance for a dashboard

const key = await proxies.poolKeys.get(existingKeyId);
const usedGB      = key.trafficUsedMB / 1024;
const remainingGB = key.trafficCapGB - usedGB;
const daysLeft    = Math.max(0, Math.ceil((new Date(key.expiresAt) - Date.now()) / 86_400_000));

Next reads