# 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. Each entry is a
hostname, `localhost`, an IP address, or `*.` plus a name (`*.example.com`). A Unicode name
is stored as punycode, a key holds at most 50 entries, and an entry that can never match an
origin is refused when you save. 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 |

The nonce is spent as soon as the timestamp passes, before the signature is checked. Retry
any refused signed request with a **new** `X-Nonce` (and a fresh `X-Timestamp`): re-sending
the same nonce answers `hmac_nonce_reuse`, whatever the first refusal was. If our nonce store
is unavailable, a signed request is refused with `503` `service_degraded` rather than let
through unchecked; retry it the same way.

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