Quickstart
Base path /api/v1 on your deployment's origin. Everything is
JSON, over HTTPS, and nothing in the API is cacheable.
The fastest way to see it work: sign up in the portal, verify your account, then
create an API token from Account → API tokens with the
generate scope. Every example below uses that token.
curl "https://your-host/api/v1/password?minPassLength=12&symbols=true" \ -H "Authorization: Bearer $PNP_TOKEN"
{
"passwords": ["orion-vega-draco47!"],
"count": 1,
"entropyBits": 41.9,
"wordPoolSize": 6
}
That's an ad-hoc call - every parameter travels on the URL, same as the standard API. Once you know which settings you actually want, save them as an endpoint instead: you get a stable URL that takes no parameters at all, and can be changed from the portal without touching whatever calls it.
curl -H "Authorization: Bearer $PNP_TOKEN" \ https://your-host/api/v1/e/8f2c4a10-3b7d-4e21-9f55-2a1c8e6b4d90
Authentication
Every call to the API is authenticated with a bearer token.
Authorization: Bearer pnp_A1B2C3D4E5F6G7H8_...
Get a token from the portal under Account → API tokens - see
Tokens for scopes and limits. Scope it to exactly what your
integration does; a token that only needs generate should
only have generate.
Calling your own endpoint straight from a web page, rather than from a server, is off by default - see Browser access under Endpoints. Nothing else in the API answers a cross-origin request.
Errors
{ "error": { "code": "invalid_credentials", "message": "That email address and password do not match." } }
| Status | Meaning |
|---|---|
| 400 | The request is malformed, or a preset cannot produce a password |
| 401 | No credential, or one that is not valid |
| 403 | Authenticated, but not allowed - wrong scope, no subscription, unlisted origin |
| 404 | Not there, or not yours. The two are indistinguishable on purpose |
| 405 | Wrong method for a real path |
| 409 | Conflicts with something that exists, or would break an invariant |
| 413 | Body over the size limit |
| 429 | Rate limited. Retry-After says how long to wait |
Two 403 codes are worth handling separately, because they mean something a client can act on rather than a mistake it made:
| Code | Meaning |
|---|---|
| subscription_required | The account has never had a subscription |
| subscription_expired | It had one and it has ended. Renewing restores access |
Generation
Scope generate. Requires a live subscription.
GET/e/{endpointId}
Runs a saved preset - the pattern to use once you know what you want.
curl -H "Authorization: Bearer $PNP_TOKEN" \ https://your-host/api/v1/e/8f2c4a10-3b7d-4e21-9f55-2a1c8e6b4d90
{
"passwords": ["orion-vega-draco47!"],
"count": 1,
"entropyBits": 41.9,
"wordPoolSize": 6,
"endpointId": "8f2c4a10-3b7d-4e21-9f55-2a1c8e6b4d90"
}
If the endpoint has allowOverrides, the query parameters
below override the stored preset for that call. Otherwise they are ignored. An
endpoint that is not yours returns 404, exactly as one that
does not exist does. A request carrying an Origin header is
additionally judged against the endpoint's browser
allow-list.
GET/password
Ad-hoc generation, with the original API's query parameters - the same shape the standard API accepts.
| Parameter | Default | Notes |
|---|---|---|
| minPassLength | 8 | 8–128 |
| maxLength | 20 | Never below minPassLength |
| numAtEnd | 2 | 0–5 trailing digits |
| numOfPasswords | 1 | 1–250 |
| lettersForNumbers | 0 | Percentage chance of leetspeak digits |
| lettersForSymbols | 0 | Percentage chance of leetspeak symbols |
| animals | true | Word category toggle |
| instruments, colours, shapes, food, sports, transport | false | |
| symbols | false | Appends one of !$%*.# |
| capitals | false | Capitalises each word |
| randCapitals | false | Randomises capitalisation |
| spacers | false | Separates words |
| excludeSymbols | - | Symbols, or the legacy letter codes e d p s f h a |
Names are matched case-insensitively, as the standard API does. With every category
switched off, animals is used. 666,
69 and 420 never appear in output unless
useStandardBanList is turned off.
Batches are capped at 250 rather than the standard service's much larger limit: a Worker has a fixed CPU budget per request. Everything else behaves identically.
Endpoints
Saved presets. Up to 50 per account.
| Route | Scope | Notes |
|---|---|---|
| GET /endpoints | endpoints:read | Yours only |
| POST /endpoints | endpoints:write | { "name", "config", "allowOverrides", "allowedOrigins" } |
| POST /endpoints/preview | endpoints:read | { "config", "count" } - runs without saving |
| GET /endpoints/{id} | endpoints:read | |
| PATCH /endpoints/{id} | endpoints:write | Partial |
| DELETE /endpoints/{id} | endpoints:write |
A preset is validated at save time by generating from it, so a combination that cannot produce a password is refused with an explanation rather than failing later in your own deployment.
config takes the query parameters from Generation above as JSON, in camelCase, plus:
| Field | Notes |
|---|---|
| wordlistIds | Up to 8 of your own lists to merge in |
| words | Up to 200 words inline |
| separator | One non-alphanumeric character, used when spacers is on |
| bannedSubstrings | Extra substrings to reject, on top of whatever else is in force |
| useStandardBanList | Default true. The standard list is 666, 69, 420 |
| banListIds | Up to 4 of your own ban lists to apply |
Browser access
allowedOrigins on an endpoint is the list of origins permitted to call it from a browser. Empty - the default - means server-side use only.
{ "allowedOrigins": ["https://app.example.com", "http://localhost:3000"] }
Entries must be a scheme, host and optional port, and nothing else. https
is required except for loopback. Wildcards and the opaque null
origin are refused. Up to 10 per endpoint.
| Request | Result |
|---|---|
No Origin header - anything server-side | Served. The allow-list is not consulted at all |
Origin matches the list | Served, with Access-Control-Allow-Origin echoing it |
Origin does not match, or the list is empty | 403 origin_not_allowed, carrying the CORS grant so the page can read the reason |
One thing worth being blunt about: a token in a page a browser downloaded is readable
by anyone who visits that page. An allow-list means a stolen copy still will not work
from a browser on an origin you did not nominate. It does nothing about
curl. Treat it as defence in depth, not as protection for
the token.
Word lists
Up to 25 lists, 5,000 words each, 64 KB per list.
| Route | Scope | Notes |
|---|---|---|
| GET /wordlists | wordlists:read | Yours, plus the built-in categories |
| POST /wordlists | wordlists:write | { "name", "words" } or { "name", "baseList" } to fork |
| GET /wordlists/builtin/{category} | wordlists:read | The built-in words |
| GET /wordlists/{id} | wordlists:read | Includes the words |
| PATCH /wordlists/{id} | wordlists:write | |
| DELETE /wordlists/{id} | wordlists:write |
A word is letters and digits only, 1–32 characters - anything that could change a password's structure is refused.
Ban lists
Words a password may never contain. Up to 25 lists, 250 entries each, 16 KB per list.
| Route | Scope | Notes |
|---|---|---|
| GET /banlists | banlists:read | Yours, plus the standard list |
| POST /banlists | banlists:write | { "name", "entries" } or { "name", "baseList": "standard" } to fork |
| GET /banlists/builtin/standard | banlists:read | The standard entries |
| GET /banlists/{id} | banlists:read | Includes the entries |
| PATCH /banlists/{id} | banlists:write | |
| DELETE /banlists/{id} | banlists:write |
An entry is anything visible without spaces, up to 32 characters - unlike a word,
because b@$$ has to be a legal thing to ban. Entries are
capped tighter than words because every one of them is matched against every
candidate password inside the generator's retry loop.
How an entry is matched
Write the plain word. bass also stops
b4ss, b@ss,
ba55, ba$$ and
BASS. That matters more here than it might elsewhere,
because the generator makes those substitutions itself:
lettersForNumbers and lettersForSymbols
turn bass into b@$$, and a literal
filter would hand it straight back.
Two rules bound that:
-
An entry with no letters is matched literally.
666means the digits. Without this rule, 6 and 9 standing forgwould make the standard entry69rejectegg+grape. -
The password is never rewritten, only the entry is expanded.
Folding both sides onto one letter looks equivalent and is not: with
iandlfolded together,banana+icecreamreads as containinganal.
A word split by the separator still counts: with spacers on,
pass + word is emitted as
pass-word, and a ban on password
catches it. These are substring bans, so a banned word inside an innocent one still
matches - vacate contains cat.
Tokens
Created from the portal, signed in, with a fresh authenticator code. Not itself an API call your integration makes.
| Route | Notes |
|---|---|
| GET /tokens | Metadata and the last four characters. Never the token |
| POST /tokens | { "name", "scopes", "expiresInDays", "code" } |
| DELETE /tokens/{id} | Takes effect immediately |
{
"token": "pnp_A1B2C3D4E5F6G7H8_...",
"scopes": ["generate"],
"expiresAt": 1770000000,
"warning": "Copy this now. It cannot be shown again."
}
Up to 25 live tokens. Maximum life one year; there is no non-expiring option.