Authentication
View as MarkdownEvery 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. 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:
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:
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.
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 / Payment Element:
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 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.
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 refusessk_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.
Test mode vs live mode#
Mode is encoded in the prefix, not a separate header:
pk_test_/sk_test_/rk_test_→ test modepk_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:
{
"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` |
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` |
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 |
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-
noneendpoints 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 scopenonebecause 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#
// 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.
Creating and rotating keys#
- Dashboard → Developers → API keys.
- Click Create key, choose the type (publishable / secret / restricted) and mode. For a restricted key, select the exact scopes.
- Copy the key on creation. The secret portion is shown once.
- 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.
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.
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):
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 uses publishable keys, which are never signed.
