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.

Request
curl "https://your-host/api/v1/password?minPassLength=12&symbols=true" \
  -H "Authorization: Bearer $PNP_TOKEN"
Response
{
  "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.

Request
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.

Header
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

Shape
{ "error": { "code": "invalid_credentials", "message": "That email address and password do not match." } }
StatusMeaning
400The request is malformed, or a preset cannot produce a password
401No credential, or one that is not valid
403Authenticated, but not allowed - wrong scope, no subscription, unlisted origin
404Not there, or not yours. The two are indistinguishable on purpose
405Wrong method for a real path
409Conflicts with something that exists, or would break an invariant
413Body over the size limit
429Rate 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:

CodeMeaning
subscription_requiredThe account has never had a subscription
subscription_expiredIt 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.

Request
curl -H "Authorization: Bearer $PNP_TOKEN" \
  https://your-host/api/v1/e/8f2c4a10-3b7d-4e21-9f55-2a1c8e6b4d90
Response
{
  "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.

ParameterDefaultNotes
minPassLength88–128
maxLength20Never below minPassLength
numAtEnd20–5 trailing digits
numOfPasswords11–250
lettersForNumbers0Percentage chance of leetspeak digits
lettersForSymbols0Percentage chance of leetspeak symbols
animalstrueWord category toggle
instruments, colours, shapes, food, sports, transportfalse
symbolsfalseAppends one of !$%*.#
capitalsfalseCapitalises each word
randCapitalsfalseRandomises capitalisation
spacersfalseSeparates 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.

RouteScopeNotes
GET /endpointsendpoints:readYours only
POST /endpointsendpoints:write{ "name", "config", "allowOverrides", "allowedOrigins" }
POST /endpoints/previewendpoints:read{ "config", "count" } - runs without saving
GET /endpoints/{id}endpoints:read
PATCH /endpoints/{id}endpoints:writePartial
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:

FieldNotes
wordlistIdsUp to 8 of your own lists to merge in
wordsUp to 200 words inline
separatorOne non-alphanumeric character, used when spacers is on
bannedSubstringsExtra substrings to reject, on top of whatever else is in force
useStandardBanListDefault true. The standard list is 666, 69, 420
banListIdsUp 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.

RequestResult
No Origin header - anything server-sideServed. The allow-list is not consulted at all
Origin matches the listServed, with Access-Control-Allow-Origin echoing it
Origin does not match, or the list is empty403 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.

RouteScopeNotes
GET /wordlistswordlists:readYours, plus the built-in categories
POST /wordlistswordlists:write{ "name", "words" } or { "name", "baseList" } to fork
GET /wordlists/builtin/{category}wordlists:readThe built-in words
GET /wordlists/{id}wordlists:readIncludes 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.

RouteScopeNotes
GET /banlistsbanlists:readYours, plus the standard list
POST /banlistsbanlists:write{ "name", "entries" } or { "name", "baseList": "standard" } to fork
GET /banlists/builtin/standardbanlists:readThe standard entries
GET /banlists/{id}banlists:readIncludes 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. 666 means the digits. Without this rule, 6 and 9 standing for g would make the standard entry 69 reject egg + grape.
  • The password is never rewritten, only the entry is expanded. Folding both sides onto one letter looks equivalent and is not: with i and l folded together, banana + icecream reads as containing anal.

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.

RouteNotes
GET /tokensMetadata and the last four characters. Never the token
POST /tokens{ "name", "scopes", "expiresInDays", "code" }
DELETE /tokens/{id}Takes effect immediately
Response, once, at creation
{
  "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.