# Authentication

Every request to `https://api.worthypayments.com/v1` is authenticated with an API key.

## Cloudflare User-Agent

Cloudflare sits in front of the API. A request whose `User-Agent` is **missing or empty**
is answered as **1010**. An HTML interstitial, not the JSON error envelope in
[Errors](./10-errors.md). Curl's default UA and Firefox pass; Chrome is not required.
Examples still send `-A` so a stripped header cannot sneak in. Browser SDKs already send a
real UA.

```
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
```

## The header

Send your key in the `x-api-key` header:

```bash
curl https://api.worthypayments.com/v1/ping \
  -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
  -H "x-api-key: sk_live_xxx"
```

A `Bearer` token is also accepted for convenience:

```bash
curl https://api.worthypayments.com/v1/ping \
  -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
  -H "Authorization: Bearer sk_live_xxx"
```

If the key is missing, invalid, expired, or revoked you get `401` with
`code: "api_key_required"` (no key sent) or `"api_key_invalid"` (unknown, expired, or
revoked). See [Errors](./10-errors.md).

---

## Key types

A key's prefix encodes both its **type** (what it can do) and its **mode** (test or live).

| Prefix | Type | Where it lives | What it can do |
|--------|------|----------------|----------------|
| `pk_live_…` / `pk_test_…` | **Publishable** | The browser (safe to embed in your web page) | `payments:charge` plus endpoints that require no scope (ping, config, confirm/retrieve a payment intent by `client_secret`, test tokens). Never server-only writes. It charges fresh `ev:`/`wp:` cards and wallets only: it cannot charge a saved card, capture, or void (`403 permission_denied`). |
| `sk_live_…` / `sk_test_…` | **Secret** | Your server. Never expose it | Everything its granted scopes allow (intents, refunds, checkout links, reads). |
| `rk_live_…` / `rk_test_…` | **Restricted** | Your server | A custom subset of scopes you choose when minting the key. |

### Publishable (`pk_`)

Designed to be shipped to the browser. The server **narrows a publishable key to
`payments:charge` plus scope-`none` endpoints**, regardless of anything stored on it.
A `pk_` key can never refund, create a payment intent or checkout link, mint a customer
session, or read data, even if those scopes were somehow attached. It *can* confirm a
payment intent whose `client_secret` your server already handed to the page. Use it with
the [SDK](./08-embedded-sdk.md) / [Payment Element](./08b-payment-element.md):

```js
const wp = WorthyPayments('pk_live_xxx');
```

**Allowed Domains:** browser keys are locked to an origin allowlist. Set the list when you
create the key under **Settings → Developers → API keys**. The create dialog exposes
**Allowed domains**, and publishable keys require at least one hostname. Update the list
on the key later without rolling the secret. A new checkout origin does not need a new
`pk_`. See [Integration hard rules](./27-integration-hard-rules.md) for the matching rules
and what happens when a request arrives with no `Origin`.

Two gates share that list: **Payment Element framing fails closed** when the list is empty
(blank iframe); the **charge API DomainGuard fails open** on an empty list (no origin
lock) and only rejects once domains are set. Once domains are set, a `pk_` request without
a matching `Origin` / `Referer` is rejected. That is what stops a stolen publishable key
from being replayed as pure S2S traffic.

`localhost` is a normal allowlist entry for local development. Send it as the `Origin`
header. Do not put `http://localhost` in the URL query
(`/v1/element-frameable?origin=http://localhost…`) (that is HTML 403 at the public edge,
not `domain_not_allowed`. See [Errors) loopback in the
URL](./10-errors.md#loopback-in-the-url).

**Merchant + env** (test vs live) come from the **key**, not from body fields. You cannot
switch merchant by forging JSON.

### Secret (`sk_`)

Full-power, server-only. Carries whatever scopes you granted it. Use it for payment
intents, refunds, checkout links, products, customer sessions, and charge reads.

> **Never ship a `sk_` key to the browser.** If a secret key is ever exposed (committed
> to a repo, embedded in client JS, pasted in a ticket), revoke it in the dashboard
> immediately and mint a new one. The [reference checkout](/demos/checkout-reference.html)
> refuses `sk_` keys outright for this reason.

### Restricted (`rk_`)

A secret-style server key limited to the exact scopes you select at creation time. Use it
to give a service the least privilege it needs, e.g. a reporting job that only holds
`payments:read`.

---

## Execution mode

Separate from scopes, every key carries an **execution mode** that decides which halves of
the API it can reach at all. Scope says *what* an endpoint needs; mode says *which surfaces
this integration uses*.

| Mode | `POST /check` | `POST /charges` | For |
|------|:---:|:---:|---|
| `ATOMIC` | yes | yes | Most integrations. One call, or the two-step flow. |
| `FRAUD_ONLY` | yes | no (`403`) | You have your own acquirer and want our screening. |
| `PAYMENT_ONLY` | no (`403`) | yes | Charging only, without the standalone advisory surface. |

The mode is read from the **key**, never from a request field. You cannot widen your own
mode by sending a parameter, and neither can anyone who obtains your key. A `FRAUD_ONLY`
key cannot move money under any circumstances.

`PAYMENT_ONLY` does **not** mean "skip fraud". Every charge runs the full engine in every
mode. See [Integration modes](./25-integration-modes.md).

---

## Test mode vs live mode

Mode is encoded in the prefix, not a separate header:

- `pk_test_` / `sk_test_` / `rk_test_` → **test mode**
- `pk_live_` / `sk_live_` / `rk_live_` → **live mode**

Test-mode charges run the **full engine**. Fraud screening, routing decisions and 3-D
Secure all execute exactly as they will in production, then settle against a sandbox
acquirer, so no real money moves. Build and verify your integration entirely in test
mode, then swap the prefix to `live` to go live. Nothing else in your code changes.

`GET /ping` echoes the resolved mode so you can confirm which key you are holding:

```json
{
  "object": "ping",
  "mode": "test",
  "key_type": "publishable",
  "merchant_account_id": "cmt3yfavu00013iokngc9rjtw",
  "api_version": "2026-01-01",
  "scopes": ["payments:charge"],
  "unrestricted": false
}
```

The response also lists the key's resolved scopes, which is usually the quickest way to
diagnose a `403 permission_denied`. A publishable key always reports exactly
`["payments:charge"]`: the server narrows every `pk_` to that regardless of what is stored
on it, so the field cannot disclose a privilege the key type did not already imply. The
list is derived from the same rules the scope guard enforces, never from the stored
permission map on its own. A listing that disagreed with the gate would be worse than
none, since the point of the field is explaining a refusal.

`unrestricted: true` appears only on a legacy secret key with no permission map at all,
which still carries full access. The dashboard labels that key "Unrestricted (legacy
key)"; reporting `scopes: []` for it would read as "denied everywhere", which is the
opposite of the truth.

---

## Scopes

A key may call an endpoint only if it holds the required scope.

**Legacy exception:** a **secret** key whose permission map is completely empty (the
pre-scopes shape still present on some older keys) keeps **full access**. That is not
least privilege. The dashboard labels it “Unrestricted (legacy key)”. A restricted key or
any key whose scopes were set and then cleared is deny-all for scoped routes. New keys
minted from the dashboard always carry an explicit scope list.

| Scope | Grants | Endpoint(s) | Server-only? |
|-------|--------|----------|:---:|
| `payments:charge` | Create a charge | `POST /charges`, capture, void | No (implicit on `pk_`) |
| `payments:read` | Read charges & refunds | `GET /charges`, `GET /charges/:id`, `GET /refunds`, `GET /refunds/:id`, `GET /customers/:id/transactions` | No |
| `payment_intents:create` | Create a payment intent (names the amount) | `POST /payment_intents` | **Yes** |
| `customer_sessions:create` | Mint a customer session for saved cards | `POST /customer_sessions` | **Yes** |
| `refunds:create` | Refund a charge | `POST /refunds` | **Yes** |
| `checkout_links:create` | Create a hosted checkout link | `POST /checkout-links` | **Yes** |
| `checkout_links:read` | List and fetch checkout links | `GET /checkout-links`, `GET /checkout-links/:id` | **Yes** |
| `products:read` | List products | `GET /products` | No |
| `tax:read` | Read tax rates, calculate tax | `GET /tax_rates`, `GET /tax_rates/:id`, `POST /tax/calculate` | No |
| `tax:write` | Create, update, delete tax rates; attach them to products | `POST /tax_rates`, `POST|DELETE /tax_rates/:id`, `POST /products/:id/tax_rates`, `DELETE /products/:id/tax_rates/:tax_rate` | **Yes** |
| `stores:read` | List and fetch storefronts | `GET /stores`, `GET /stores/stats`, `GET /stores/:id` | No |
| `metrics:read` | Read KPI tiles | `GET /metrics` | No |
| `customers:read` | Read customers & saved cards | `GET /customers`, `GET /customers/:id`, `GET /customers/:id/payment_methods` | No |
| `customers:write` | Create customers, save/delete cards | `POST /customers`, `POST|DELETE /customers/:id/payment_methods` | **Yes** |
| `plans:read` | List and fetch plans | `GET /plans`, `GET /plans/:id` | No |
| `plans:write` | Create a plan | `POST /plans` | **Yes** |
| `subscriptions:read` | Read subscriptions | `GET /subscriptions`, `GET /subscriptions/:id` | No |
| `subscriptions:write` | Create, modify, cancel, or test-bill a subscription | `POST /subscriptions`, `PATCH`/`DELETE /subscriptions/:id`, `POST /test/subscriptions/:id/bill` | **Yes** |
| `invoices:read` | List and fetch invoices | `GET /invoices`, `GET /invoices/:id` | No |
| `invoices:write` | Email an invoice | `POST /invoices/:id/send` | **Yes** |
| `webhooks:read` | Read webhook endpoints | `GET /webhook-endpoints`, `GET /webhook-endpoints/:id` | No |
| `webhooks:write` | Manage webhook endpoints | `POST|DELETE /webhook-endpoints`, roll-secret | **Yes** |
| `events:read` | Re-fetch past events | `GET /events`, `GET /events/:id` | No |
| `disputes:read` | Poll disputes and pre-dispute alerts | `GET /disputes`, `GET /disputes/:id`, `GET /pre_dispute_alerts`, `GET /pre_dispute_alerts/:id` | No |
| `threeds:read` | Read 3DS config & sessions | `GET /3ds/config`, `GET /3ds/sessions/:id` | No |
| `threeds:create` | Open / retry a 3DS session | `POST /3ds/sessions`, `POST /3ds/retry-decline` | **Yes** |
| `check:read` | Pre-checkout fraud evaluation | `POST /check`, `POST /check/recheck` | No |
| `blocklist:read` | Read the blocklist | `GET /blocklist` | No |
| `report:write` | Report abuse to the blocklist | `POST /report` | **Yes** |
| `fraud:read` | Read fraud rules, workflow routes, a customer's Require 3DS flag | `GET /fraud/rules`, `GET /payment_workflow/routes`, `GET /customers/:id/three_ds` | No |
| `fraud:write` | Set / clear a customer's Require 3DS flag | `POST /customers/:id/three_ds`, `DELETE /customers/:id/three_ds` | **Yes** |

> Scope-`none` endpoints need only a valid key: `/ping`, `/wallet-config`,
> `/evervault-config`, `/wp-config`, `/kount-config`, `/element-frameable`,
> `/test/tokens`, `POST /payment_intents/:client_secret/confirm`, `GET
> /payment_intents/:client_secret`, and customer-session payment-method reads that carry a
> session secret. Confirm/retrieve are scope `none` because the **client secret is the
> capability**. A publishable key never needs a write scope to finish a payment. Creating
> the intent stays server-only (`payment_intents:create`) because that is where the amount
> is decided.

**Server-only scopes** can never be satisfied by a publishable key. If a `pk_` key
attempts one of those endpoints, the request is rejected with `403` and
`code: "permission_denied"`. Creating a payment intent is server-only on purpose: that is
where the amount is decided, so a browser key must never mint one.

A publishable key is implicitly limited to `payments:charge` and the no-scope endpoints.
Within `payments:charge` it can only create a charge with a freshly tokenized card or a
wallet. Charging a saved card, `POST /v1/charges/:id/capture` and `POST
/v1/charges/:id/void` need a secret key: the publishable key is public, and a void can
become a full refund.

`disputes:read` is the one scope a new secret key does **not** get by default. Dispute and
pre-dispute payloads carry card and cardholder detail (BIN, last 4, ARN, order id) that
most integrations never need, so it is granted deliberately rather than inherited, tick it
under Dashboard → **Developers** on the key that polls. It holds **no** read scopes, so
even `payments:read` is denied to it.

### Scope errors

```jsonc
// 403 — missing scope (secret without the grant, or any publishable on a server-only route)
{ "error": { "type": "invalid_request_error", "code": "permission_denied",
             "message": "API key missing scope refunds:create" } }
```

Branch on `code` (`permission_denied`), not on the exact `message` wording. Every error
follows the same `{ "error": { "type", "code", "message" } }` envelope. See
[Errors](./10-errors.md).

---

## Creating and rotating keys

1. Dashboard → **Developers → API keys**.
2. Click **Create key**, choose the type (publishable / secret / restricted) and mode.
   For a restricted key, select the exact scopes.
3. Copy the key on creation. The secret portion is shown **once**.
4. To rotate, create a new key, deploy it, then revoke the old one. Revocation is
   immediate; in-flight requests with the old key start failing with `401`.

Keys are scoped to a single merchant account and respect any IP allowlist configured on
them. Set it when you create a secret or restricted key (**Allowed server IPs** in the
create dialog): exact IPv4 or IPv6 addresses, no ranges. A request from any other address
is refused with `401 api_key_invalid`. The dashboard sets this list only when the key is
created (unlike allowed domains, which you can edit later), so to change it, create a new
key and rotate.

---

## Request signing (HMAC)

Optional, and **off unless you enable it** when you create a secret or restricted key
(**Require signed requests (HMAC)** in the create dialog). The signing secret is shown
once, next to the key. Once enabled, the key stops working without a valid signature, so
turn it on in test mode first.

> **Hard rule when HMAC is on:** sign the **raw body** bytes you send. Re-serializing
> JSON (pretty-print, key reorder) changes the signature. Fail-closed if the replay
> store is down. See [Integration hard rules §12](./27-integration-hard-rules.md#12-hmac-if-enabled-on-the-key).

Signing proves the request body was not altered in transit and cannot be replayed. Send
three headers alongside your API key:

| Header | Value |
|---|---|
| `X-Timestamp` | Unix seconds. Rejected if more than **300s** from server time. |
| `X-Nonce` | A random string, unique per request. Accepted **once**, then remembered for 300s. |
| `X-Signature` | Hex `HMAC-SHA256(secret, "<timestamp>.<nonce>.<raw body>")` |

The signed payload is the timestamp, the nonce and the **raw request body** joined by
literal dots. For a GET with no body, use an empty string in the third position. Sign the
exact bytes you send. Re-serializing the JSON changes the signature.

```js
import { createHmac, randomUUID } from "node:crypto";

const body = JSON.stringify({ amount: 4999, currency: "usd", card });
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomUUID();
const signature = createHmac("sha256", process.env.WP_HMAC_SECRET)
  .update(`${timestamp}.${nonce}.${body}`)
  .digest("hex");

await fetch("https://api.worthypayments.com/v1/charges", {
  method: "POST",
  headers: {
    "x-api-key": process.env.WP_SECRET_KEY,
    "Idempotency-Key": randomUUID(),
    "Content-Type": "application/json",
    "X-Timestamp": timestamp,
    "X-Nonce": nonce,
    "X-Signature": signature,
  },
  body,                       // the SAME string that was signed
});
```

Python (same string: timestamp, nonce, raw body, joined by dots):

```python
import hashlib, hmac, json, os, time, uuid, urllib.request

body = json.dumps({"amount": 4999, "currency": "usd"}, separators=(",", ":"))
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
secret = os.environ["WP_HMAC_SECRET"].encode()
signature = hmac.new(
    secret, f"{timestamp}.{nonce}.{body}".encode(), hashlib.sha256
).hexdigest()

req = urllib.request.Request(
    "https://api.worthypayments.com/v1/charges",
    data=body.encode(),
    method="POST",
    headers={
        "x-api-key": os.environ["WP_SECRET_KEY"],
        "Idempotency-Key": str(uuid.uuid4()),
        "Content-Type": "application/json",
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
        "X-Signature": signature,
    },
)
```

`curl` cannot HMAC for you. Compute the hex the same way, then:

```
curl https://api.worthypayments.com/v1/charges \
  -H "x-api-key: $WP_SECRET_KEY" \
  -H "Idempotency-Key: $NONCE" \
  -H "Content-Type: application/json" \
  -H "X-Timestamp: $TIMESTAMP" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIGNATURE" \
  --data-binary @body.json
```

`--data-binary` keeps the exact bytes you signed. Pretty-printed JSON will not verify.

Failures return `401` with a specific code, so you can tell a clock problem from a
genuine mismatch:

| Code | Meaning |
|---|---|
| `hmac_headers_missing` | One or more of the three headers is absent |
| `hmac_invalid` | Signature does not match the body |
| `hmac_timestamp_invalid` | `X-Timestamp` is not an integer |
| `hmac_timestamp_skew` | More than 300s from server time. Check your clock |
| `hmac_nonce_reuse` | This nonce was already used within the last 300s |
| `hmac_secret_missing` | Signing is enabled on the key but no secret is provisioned |

> Never sign in the browser: signing requires the secret, and a secret in page source is
> a compromised secret. The [browser SDK](./08-embedded-sdk.md) uses publishable keys,
> which are never signed.
