Authentication

View as Markdown

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


Key types#

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

PrefixTypeWhere it livesWhat it can do
pk_live_… / pk_test_…PublishableThe 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_…SecretYour server. Never expose itEverything its granted scopes allow (intents, refunds, checkout links, reads).
rk_live_… / rk_test_…RestrictedYour serverA 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:

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

ModePOST /checkPOST /chargesFor
ATOMICyesyesMost integrations. One call, or the two-step flow.
FRAUD_ONLYyesno (403)You have your own acquirer and want our screening.
PAYMENT_ONLYno (403)yesCharging 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 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.

ScopeGrantsEndpoint(s)Server-only?
payments:chargeCreate a chargePOST /charges, capture, voidNo (implicit on pk_)
payments:readRead charges & refundsGET /charges, GET /charges/:id, GET /refunds, GET /refunds/:id, GET /customers/:id/transactionsNo
payment_intents:createCreate a payment intent (names the amount)POST /payment_intentsYes
customer_sessions:createMint a customer session for saved cardsPOST /customer_sessionsYes
refunds:createRefund a chargePOST /refundsYes
checkout_links:createCreate a hosted checkout linkPOST /checkout-linksYes
checkout_links:readList and fetch checkout linksGET /checkout-links, GET /checkout-links/:idYes
products:readList productsGET /productsNo
tax:readRead tax rates, calculate taxGET /tax_rates, GET /tax_rates/:id, POST /tax/calculateNo
tax:writeCreate, update, delete tax rates; attach them to productsPOST /tax_rates, `POSTDELETE /tax_rates/:id, POST /products/:id/tax_rates, DELETE /products/:id/tax_rates/:tax_rate`
stores:readList and fetch storefrontsGET /stores, GET /stores/stats, GET /stores/:idNo
metrics:readRead KPI tilesGET /metricsNo
customers:readRead customers & saved cardsGET /customers, GET /customers/:id, GET /customers/:id/payment_methodsNo
customers:writeCreate customers, save/delete cardsPOST /customers, `POSTDELETE /customers/:id/payment_methods`
plans:readList and fetch plansGET /plans, GET /plans/:idNo
plans:writeCreate a planPOST /plansYes
subscriptions:readRead subscriptionsGET /subscriptions, GET /subscriptions/:idNo
subscriptions:writeCreate, modify, cancel, or test-bill a subscriptionPOST /subscriptions, PATCH/DELETE /subscriptions/:id, POST /test/subscriptions/:id/billYes
invoices:readList and fetch invoicesGET /invoices, GET /invoices/:idNo
invoices:writeEmail an invoicePOST /invoices/:id/sendYes
webhooks:readRead webhook endpointsGET /webhook-endpoints, GET /webhook-endpoints/:idNo
webhooks:writeManage webhook endpoints`POSTDELETE /webhook-endpoints`, roll-secret
events:readRe-fetch past eventsGET /events, GET /events/:idNo
disputes:readPoll disputes and pre-dispute alertsGET /disputes, GET /disputes/:id, GET /pre_dispute_alerts, GET /pre_dispute_alerts/:idNo
threeds:readRead 3DS config & sessionsGET /3ds/config, GET /3ds/sessions/:idNo
threeds:createOpen / retry a 3DS sessionPOST /3ds/sessions, POST /3ds/retry-declineYes
check:readPre-checkout fraud evaluationPOST /check, POST /check/recheckNo
blocklist:readRead the blocklistGET /blocklistNo
report:writeReport abuse to the blocklistPOST /reportYes
fraud:readRead fraud rules, workflow routes, a customer's Require 3DS flagGET /fraud/rules, GET /payment_workflow/routes, GET /customers/:id/three_dsNo
fraud:writeSet / clear a customer's Require 3DS flagPOST /customers/:id/three_ds, DELETE /customers/:id/three_dsYes

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.


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.

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

HeaderValue
X-TimestampUnix seconds. Rejected if more than 300s from server time.
X-NonceA random string, unique per request. Accepted once, then remembered for 300s.
X-SignatureHex 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:

CodeMeaning
hmac_headers_missingOne or more of the three headers is absent
hmac_invalidSignature does not match the body
hmac_timestamp_invalidX-Timestamp is not an integer
hmac_timestamp_skewMore than 300s from server time. Check your clock
hmac_nonce_reuseThis nonce was already used within the last 300s
hmac_secret_missingSigning 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.