# Quickstart

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](#7-alternative-hosted-checkout-link) for a hosted page you redirect to. If you
tokenize cards on your own server, see [step 8](#8-alternative-server-to-server-charge).

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](./02-authentication.md#cloudflare-user-agent) and
[Errors](./10-errors.md#cloudflare-1010).

---

## 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](./02-authentication.md).

**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](https://guide.nmi.com/). See
[Testing](./15-testing.md#your-own-nmi-sandbox-key).

**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](./21-going-live.md).

---

## 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](./10-errors.md#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](./10-errors.md#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 / 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](./12-idempotency.md). |

```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](./08-embedded-sdk.md).

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](./08b-payment-element.md).

---

## 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?](./08b-payment-element.md#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](./16-webhooks.md) · test cards in [Testing](./15-testing.md).

---

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

```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](./04-checkout-links.md).

---

## 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](./03-charges.md) and [Card tokenization](./28-card-tokenization.md).

---

## Next steps

- [Payment Element](./08b-payment-element.md) for appearance, locale, layout and saved cards
- [Authentication](./02-authentication.md) for key types and test vs live
- [Webhooks](./16-webhooks.md) for settling `processing` and reconciling out of band
- [Errors](./10-errors.md). The error envelope, the codes you hit while integrating, and
  retries. ([Tokenization error codes](./30-error-codes.md) is narrower: the seven `wp:`
  rail codes only, which you will not see on the Payment Element path.)
- [Integration hard rules](./27-integration-hard-rules.md) before you go live
- [Going live](./21-going-live.md) for the switch to `pk_live_` / `sk_live_`
