Quickstart

View as Markdown

Go from zero to a successful charge.

The browser integration is the Payment Element. Your server names the price and creates a payment intent; your page mounts our element against it. The card fields live in an iframe served from our origin, so a PAN never enters your page. That is the condition for PCI SAQ A (~30 requirements) rather than SAQ A-EP (~139).

If you would rather not run any card UI at all, skip to step 7 for a hosted page you redirect to. If you tokenize cards on your own server, see step 8.

Amounts are integers in the smallest currency unit. $49.99 is 4999. A decimal is rejected, not rounded.

Base URL for every call below:

https://api.worthypayments.com/v1

Every curl below sends a browser User-Agent. Cloudflare answers a request with an empty UA as 1010 (HTML, not JSON). Curl's default UA is fine; empty is not. Copy if you strip 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 Payment Element in a real browser already has a Chrome UA. See Authentication and Errors.


1. Create an API key#

In the dashboard, go to Developers → API keys.

  • A publishable key (pk_test_… / pk_live_…) belongs in your web page.
  • A secret key (sk_test_… / sk_live_…) stays on your server. It creates intents and checkout links, refunds, and reads data.

Start on test keys. Test-mode charges run the full engine (fraud, routing, 3-D Secure) but never reach a live acquirer. Details in Authentication.

Test mode needs your own NMI sandbox key. Test charges, saved cards and refunds run on your own NMI sandbox account. Until its security key is in the dashboard (Settings > Integrations, NMI card, with the dashboard in test mode), they answer 403 sandbox_key_required. No sandbox account yet: create one at guide.nmi.com. See Testing.

Live keys need business verification first. Until your KYB is approved, minting a pk_live_/sk_live_ is refused, and any live request made with one already issued returns 403 account_not_activated, including POST /v1/payment_intents and POST /v1/check. This is not a scope problem and no amount of retrying clears it. Build against test keys; see Going live.


2. Allowlist your domains#

When you create a publishable key in Settings → Developers → API keys, set Allowed domains to the exact hostnames that will embed the element: production plus whatever you develop on (localhost, staging, etc.). The create dialog requires at least one domain for publishable keys.

Update the list on the key later without rolling the secret. A new checkout origin does not need a new pk_. Settings → Developers → API keys → the globe on the key.

Empty allowlist ≠ open element. For the Payment Element, framing fails closed: no domains configured means frame-ancestors 'none' and a blank iframe. That is the opposite of the charge API's origin guard, which treats an empty list as "no origin lock" for server-to-server calls. You must have the list set before the element will render. Framing otherwise requires an https parent origin. Loopback HTTP is the TLS exception for a local development server: http://localhost, http://127.0.0.1, and http://[::1]. A downloaded HTML file (file://) will not frame. List localhost on the key like any other domain. The secret key stays on your server.

The browser sends that origin as the Origin header. Do not put http://localhost in the URL (?origin=http://localhost:3000 on /v1/element-frameable). The public edge returns HTML 403 and the API never sees the request. Local development is allowed; that query shape is not. Prove the list with:

bash
curl https://api.worthypayments.com/v1/element-frameable \
  -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: pk_test_xxx" \
  -H "Origin: http://localhost:3000"

See Errors, loopback in the URL.

Once domains are set, a mismatched origin is refused (domain_not_allowed on charge paths; a blank frame for the element). That blank frame is the most common first-run failure, and what it tells you is worse than nothing:

  • The browser console carries the real reason, from the browser rather than from us: Framing 'https://element.worthypayments.com/' violates the following Content Security Policy directive: "frame-ancestors 'none'".
  • onError fires with wallet_error: Invalid API key context. That names neither the key nor the allowlist, and "wallet" sends you to Apple Pay and Google Pay, which have nothing to do with it. Read it as this publishable key cannot frame on this origin: the key is wrong for the account, or this origin is not on its allowed domains.

Check the origin against the key before you change anything else. element-frameable above answers it in one call.

Wildcards match one label. *.shop.com covers www.shop.com but not a.b.shop.com, so list each level you actually use. A wildcard does not cover the apex: *.shop.com leaves shop.com refused, so list both if buyers check out at both.


3. Verify the key#

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_test_xxx"

Pinging the publishable key needs an Origin, and the error for forgetting it is misleading. Step 1 hands you two keys, so the natural next move is to ping both. A pk_ is a browser key and the server requires an origin from it:

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: pk_test_xxx" -H "Origin: https://your-site.example"

Without it you get 403 domain_not_allowed — "Origin header required for browser keys". The code reads like your domain is not on the list, but your allowlist was never consulted; there was no origin to check it against. Do not go and edit your allowed domains; add the header. A pk_ always reports exactly ["payments:charge"] however you scoped it, so that is the correct answer, not a narrowed one. A different 403 is HTML from the public edge (<h1>403 Forbidden</h1>, no JSON): ?origin=http://localhost in the URL. Local keys still work; send Origin and omit that query. See Errors, loopback in the URL.

On Windows. Every example here is POSIX-quoted. In cmd.exe each -H needs its own closed pair of double quotes and a JSON body needs \" escaping:

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_test_xxx"
curl https://api.worthypayments.com/v1/payment_intents -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_test_xxx" -H "Idempotency-Key: order_1234" -H "Content-Type: application/json" -d "{\"amount\":4999,\"currency\":\"usd\"}"

An unbalanced quote does not error, cmd folds the rest of the line into the previous header, so -H lands on the end of your API key and you get 401 api_key_invalid, which reads as a bad key. PowerShell handles the double-quoted form above unchanged.

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

mode is test or live from the key prefix; key_type is secret or publishable. A 401 means the key is wrong.

scopes lists what this key can actually reach, as resource:action strings, and is the fastest way to answer "why am I getting a 403". It is derived from the same rules the guard enforces, not from the stored permission map, so it cannot disagree with the gate. A publishable key always reports exactly ["payments:charge"]. The server narrows it to that regardless of what is stored, so the field discloses no privilege you did not already know from the key type. unrestricted: true marks a legacy secret key with no permission map at all, which still has full access; the dashboard labels it "Unrestricted (legacy key)".


4. Create a payment intent on your server#

The amount is set here with your secret key. Scope payment_intents:create is required (server-only; a pk_ is refused). Confirm later uses scope none: the client_secret is the capability.

bash
curl https://api.worthypayments.com/v1/payment_intents \
  -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_test_xxx" \
  -H "Idempotency-Key: order_1234" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 4999,
    "currency": "usd",
    "description": "Order #1234",
    "reference": "order_1234",
    "receipt_email": "jane@example.com"
  }'
Field / headerRequiredNotes
amountYesInteger minor units (4999 = $49.99). Decimals rejected.
currencyNoDefaults to usd
descriptionNoLine label on the intent
referenceNoYour order/cart id. It is echoed back on the money webhooks as data.object.detail.orderReference, which is the direct join. Storing it against the returned id + client_secret is still worth doing as a fallback.
customerNoExisting customer id (not in the example)
receipt_emailNoReceipt destination. Send this or billing.email on confirm, with neither, the confirm fails failed_precondition / customer email is required
Idempotency-KeyYesHeader, or body idempotency_key. Key it on the order, not the click. Same key → same intent. Omitting it is 400 idempotency_key_required, checked before the body is validated, so a request missing both the key and the amount reports the key. See Idempotency.
json
{
  "id": "pi_4b124c602f49458b92204957ee5b21b6",
  "object": "payment_intent",
  "amount": 4999,
  "currency": "usd",
  "status": "requires_payment_method",
  "customer": null,
  "receipt_email": "jane@example.com",
  "latest_charge": null,
  "client_secret": "pi_4b124c602f49458b92204957ee5b21b6_secret_rz2MzQr49QlZ3TW2UWq5cMI6xb9dd0wUMl7h",
  "expires_at": "2026-01-01T12:30:00.000Z",
  "created": "2026-01-01T12:00:00.000Z"
}

Hand client_secret to the browser (and nothing else sensitive). It appears on create only; retrieve does not re-echo it.


5. Mount the Payment Element#

html
<div id="payment"></div>
<script src="https://app.worthypayments.com/sdk/v1/worthypayments.umd.js"></script>
<script>
  const wp = WorthyPayments('pk_test_xxx');

  wp.mountPaymentElement('#payment', {
    clientSecret: 'pi_4b124c602f49458b92204957ee5b21b6_secret_rz2MzQr49QlZ3TW2UWq5cMI6xb9dd0wUMl7h',   // from your server, step 4
    // Optional: hide address you already have, keep AVS:
    // fields: { billingDetails: { address: 'never' } },
    // defaultValues: { billingDetails: { address: { line1, city, postal_code, country } } },
    onSuccess: (result) => {
      if (result.status === 'succeeded') {
        window.location = '/thanks?pi=' + result.paymentIntentId;
      }
      // 'processing' is handled in step 6.
    },
    onError: (err) => {
      showMessage(err.message);
    },
  });
</script>

Pin /sdk/v1/ in the script URL (unversioned always tracks latest and will change under you). Defaults for apiBase / elementOrigin match production; override only for staging. See Embedded SDK.

There is no amount in this call. Confirm refuses amount / currency (parameter_unknown). Card entry, wallets, 3-D Secure and saved cards run inside the element. Optional mount fields: appearance, locale, layout, fields, defaultValues, customerSessionSecret. See Payment Element.


6. Handle the outcomes#

onSuccess receives this named subset. Not the whole GET /v1/charges/{id} object:

ts
{
  paymentIntentId: string,
  status: 'succeeded' | 'processing',
  transactionId: string | null,          // charge id; never null on 'succeeded'
  charge?: {
    id: string,
    object: 'charge',
    amount: 4999,
    currency: 'usd',
    status: 'succeeded',
    paid: true,
    captured: true,
    auth_code: 'TESTOK',
    processor_transaction_id: 'TEST-982e43e5',  // gateway id (NMI transactionid)
    card: { brand: 'visa', last4: '4242' },
    billing_details: { name: 'Jane Doe', email: 'jane@example.com' },
    // no processor, mid, network_transaction_id, avs_result, card.bin
  },
  card_save?: { status, payment_method, failure_code, failure_message }, // only when the buyer ticked "save this card"
}

card_save is explained in Did the card get saved?.

charge is present whenever it could be read back and omitted when it could not, enough to render "Paid $49.99 with Visa ••4242" without a second call.

No token or vault id crosses out of the frame, and no PAN or CVV. Those are instrument references; handing one to your page would put a reusable credential back on your side of the boundary. card.bin is not there either, nor the merchant and acquirer facts the server charge carries (mid, processor = Admin → Acquirers → Code of the acquirer Payment Workflow chose for this sale, arn, rrn, settlement, AVS / CVV). Those stay behind your secret key. GET /v1/charges/{id} omits a column when the row is empty. brand and last4 are not references: they are what the buyer typed and what their receipt shows.

Use them to display, never to decide. Fulfilment reads the charge on your server or the charge.succeeded webhook, because a value that reached your page through a browser is a value a browser could have edited.

succeeded means the money moved. Fulfil the order.

processing means the outcome is not known yet. Do not fulfil. Wait for the charge.succeeded webhook. The webhook body has data.object.transactionId (camelCase. The payload is a protobuf message, unlike the REST fields elsewhere in this guide) and, if you set one, your own data.object.detail.orderReference. It does not carry paymentIntentId. If you set no reference, persist id + client_secret at step 4, then GET /v1/payment_intents/{client_secret} and read latest_charge. Treating processing as success is the one mistake that ships real money out the door.

A decline does not throw and does not reach onSuccess. It arrives on onError with code: 'card_declined', and the element stays mounted so the buyer can try another card. Do not unmount it or navigate away. The element shows its own decline message; use onError for your own analytics or surrounding UI.

Webhooks (do this before production): register an HTTPS endpoint for at least charge.succeeded in Settings → Developers → Webhooks, then verify X-Webhook-Signature. Webhooks · test cards in Testing.


No card UI on your side. Create a link server-side and redirect the buyer. Scope: checkout_links:create. For FIXED, set amount (cents, typically ≥ 50).

bash
curl https://api.worthypayments.com/v1/checkout-links \
  -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_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order #1234",
    "amount": 4999,
    "currency": "usd",
    "link_type": "FIXED"
  }'
json
{
  "id": "9f2c1b84-77ad-4e0c-9c31-0b6e5d2a4413",
  "object": "checkout_link",
  "url": "https://app.worthypayments.com/checkout/abc123",
  "code": "abc123",
  "active": true
}

Redirect to url. More in Checkout Links.


8. Alternative: server-to-server charge#

For an integrator who already tokenizes cards elsewhere and posts from a backend. This path does not involve our SDK, and collecting the card yourself puts you in SAQ A-EP.

POST /v1/charges takes a tokenized card on one of two rails. Never a raw PAN.

  • ev: rail. Per-field Evervault tokens: card.number and card.cvv.
  • wp: rail. A single card.wp_token, a wp:v1: envelope carrying PAN, expiry and CVV together. Because the envelope holds all three, there is no wp: CVV field, and outer exp_month / exp_year are optional on this rail (expiry is already sealed).
bash
curl https://api.worthypayments.com/v1/charges \
  -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_test_xxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 4999,
    "currency": "usd",
    "card": {
      "wp_token": "wp:v1:...",
      "bin": "411111",
      "last4": "1111",
      "name": "Jane Doe"
    },
    "customer": { "email": "jane@example.com" }
  }'

The full contract, the ev: variant, requires_action and decline handling are in Charges and Card tokenization.


Next steps#