Quickstart
View as MarkdownGo 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:
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'". onErrorfires withwallet_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#
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. Apk_is a browser key and the server requires an origin from it: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. Apk_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://localhostin the URL. Local keys still work; sendOriginand omit that query. See Errors, loopback in the URL.
On Windows. Every example here is POSIX-quoted. In
cmd.exeeach-Hneeds 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,
cmdfolds the rest of the line into the previous header, so-Hlands on the end of your API key and you get401 api_key_invalid, which reads as a bad key. PowerShell handles the double-quoted form above unchanged.
{
"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.
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 / header | Required | Notes |
|---|---|---|
amount | Yes | Integer minor units (4999 = $49.99). Decimals rejected. |
currency | No | Defaults to usd |
description | No | Line label on the intent |
reference | No | Your 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. |
customer | No | Existing customer id (not in the example) |
receipt_email | No | Receipt destination. Send this or billing.email on confirm, with neither, the confirm fails failed_precondition / customer email is required |
Idempotency-Key | Yes | Header, 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. |
{
"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#
<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:
{
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.
7. Alternative: hosted checkout link#
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).
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"
}'
{
"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.numberandcard.cvv.wp:rail. A singlecard.wp_token, awp:v1:envelope carrying PAN, expiry and CVV together. Because the envelope holds all three, there is nowp:CVV field, and outerexp_month/exp_yearare optional on this rail (expiry is already sealed).
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#
- Payment Element for appearance, locale, layout and saved cards
- Authentication for key types and test vs live
- Webhooks for settling
processingand reconciling out of band - Errors. The error envelope, the codes you hit while integrating, and
retries. (Tokenization error codes is narrower: the seven
wp:rail codes only, which you will not see on the Payment Element path.) - Integration hard rules before you go live
- Going live for the switch to
pk_live_/sk_live_
