# Onboard a user in a mobile/native app (get API keys via PKCE)

Let a native or mobile app get a user's own Porkbun API keys **without ever handling their password**. The user signs in — or creates a brand-new Porkbun account — in the system browser, approves once, and control returns to your app with the keys delivered securely out-of-band (PKCE, RFC 7636).

This one flow handles **both** cases automatically: an existing user logs in, a new user taps "Create a New Account" on the same screen. Your app code does not branch on which.

## The flow at a glance

1. App generates a PKCE `codeVerifier` + `codeChallenge`.
2. App calls `POST /apikey/request` with the challenge and its `returnUrl` → gets an `authUrl`.
3. App opens `authUrl` in the **system browser** (ASWebAuthenticationSession / Custom Tabs).
4. User logs in or creates an account, then approves.
5. Browser redirects to your `returnUrl` with `status` + `requestToken`; your app captures it.
6. App calls `POST /apikey/retrieve` with the `requestToken` + `codeVerifier` → receives both keys **once**.

## 1. Generate the PKCE pair

`codeVerifier` is a high-entropy random string you keep in memory. `codeChallenge` is its base64url-unpadded SHA-256.

```swift
// iOS (Swift, CryptoKit)
import CryptoKit
let verifier = Data((0..<32).map { _ in UInt8.random(in: 0...255) }).base64URLEncoded()   // 43 chars
let challenge = Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded()
// base64URLEncoded(): standard base64, then + → -, / → _, strip '='
```

```kotlin
// Android (Kotlin)
val verifier = ByteArray(32).also { SecureRandom().nextBytes(it) }
    .let { Base64.encodeToString(it, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) }
val challenge = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())
    .let { Base64.encodeToString(it, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) }
```

## 2. Request (no credentials needed)

```bash
curl -X POST https://api.porkbun.com/api/json/v3/apikey/request \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Acme Domains (iOS)",
    "codeChallenge": "<challenge from step 1>",
    "codeChallengeMethod": "S256",
    "returnUrl": "https://app.acme.example/porkbun/callback",
    "scopes": ["dns", "domains"]
  }'
```

- `name` — shown to the user on the approval screen. Use your app's name.
- `returnUrl` — **must be an HTTPS URL your app claims as a Universal Link (iOS) / App Link (Android)**. Custom URI schemes (`myapp://`) are **rejected** — another app could register the same scheme and intercept the callback. `returnUrl` requires `codeChallenge`.

Response: `{ "requestToken": "...", "authUrl": "https://porkbun.com/account/apiKeyApproval/<token>", "expiration": "...", "deliveryMode": "pkce" }`. The user has **30 minutes** to approve — enough for a brand-new user to create an account and verify their email in the same session. (After approval, the retrieve step in step 5 has its own 10-minute window.)

## 3. Open `authUrl` in the system browser — not a WebView

Use **`ASWebAuthenticationSession`** (iOS) or **Custom Tabs** (Android). Do **not** use an embedded `WKWebView`/`WebView`: the user must see the real `porkbun.com` address bar (phishing-resistance) and reuse real Porkbun cookies, and an embedded webview could read the password. This is the difference between a secure integration and an insecure one.

```swift
let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: nil) { callbackURL, error in
    // Universal Link handling also delivers this; see step 4
}
session.presentationContextProvider = self
session.start()
```

On this screen the user either signs in **or taps "Create a New Account"** and completes normal Porkbun signup (email verification, etc.). Either way they land back on the approval screen and approve. You don't handle any of that — it's all in the browser.

## 4. Handle the return redirect

After the decision, Porkbun redirects the browser to your `returnUrl` with query params appended:

```
https://app.acme.example/porkbun/callback?status=approved&requestToken=<token>
```

`status` is `approved` or `denied`. On `approved`, grab the `requestToken` (it matches the one from step 2). **No key is in this URL** — only the token. If `status=denied`, stop.

## 5. Retrieve the keys (once)

```bash
curl -X POST https://api.porkbun.com/api/json/v3/apikey/retrieve \
  -H 'Content-Type: application/json' \
  -d '{"requestToken":"<token>","codeVerifier":"<verifier from step 1>"}'
```

Returns `{ "status":"SUCCESS", "apikey":"pk1_...", "secretapikey":"sk1_..." }`. The **secret is returned exactly once** — persist it immediately in the device keychain/keystore. A later `/retrieve` returns the public key only (`SECRET_ALREADY_CLAIMED`). Retrieve within 10 minutes of approval.

From here, authenticate normal API calls with `apikey` + `secretapikey` (or the `X-API-Key` / `X-Secret-API-Key` headers).

## Why this is safe

- The **password never touches your app** — auth happens on porkbun.com in the system browser.
- The **secret key never rides the redirect or the browser** — it's delivered only to the holder of the `codeVerifier` (your app) via `/apikey/retrieve`. An intercepted redirect leaks nothing usable.
- HTTPS-claimed Universal Links can't be hijacked the way custom URI schemes can.

## Notes & error codes

- Without `scopes` the issued key is a **full-account key**; with `scopes` it can do only those (plus `read`), and the approval page tells the user exactly that. Ask for the least your app needs. Tell users they can restrict it (allowed domains, source IPs, and permissions such as Read-only or Manage, but no purchases) or revoke it anytime at porkbun.com/account/api. Keys created this way are labeled with your app name there.
- `INVALID_INPUT` on `/apikey/request` — `returnUrl` isn't HTTPS, is a custom scheme, or was sent without `codeChallenge`.
- `CODE_VERIFIER_REQUIRED` / `INVALID_CODE_VERIFIER` on `/retrieve` — send the exact `codeVerifier` from step 1.
- `REQUEST_EXPIRED` — the 30-minute approval window, or the 10-minute post-approval retrieve window, lapsed; start over at step 2.
- `SECRET_ALREADY_CLAIMED` — the secret was already returned once; create a new request if it wasn't stored.


---

## More

- Guides (how-tos): https://porkbun.com/llms/guides
- Topic index: https://porkbun.com/llms
- Full reference (one file): https://porkbun.com/llms-full.txt
- OpenAPI spec (full schemas): https://porkbun.com/api/json/v3/spec
- Short overview: https://porkbun.com/llms.txt
- Official MCP server: https://porkbun.com/mcp (`npx -y @porkbunllc/mcp-server`)
- Create API keys: https://porkbun.com/account/api
