# Soap Payments — Agent Skill Reference

This file is served to AI agents by Soap's documentation MCP server, and is the
first thing an agent typically sees before it searches anything else. Read it
fully before generating any Soap integration code.

## Scope: this MCP server is documentation search, not a payments tool

The MCP server behind these docs is **read-only search over documentation**.
It exposes exactly three tools: `search_soap`, `query_docs_filesystem_soap`,
and `submit_feedback`. It cannot move money, create checkouts, create
customers, call the Soap API in any way, send a test webhook, or read an
account's balance or transaction history. An agent has previously assumed
otherwise and wasted effort trying to "execute" a payment through this server.
If you need to actually do any of those things, you need your own API key and
a direct HTTP call to the Soap API — this server can only help you find out
how.

## Corrections to prior generated guidance

An older auto-generated version of this file contained four errors. If you
have seen Soap integration code or explanations before reading this, discard
anything that conflicts with the corrections below.

1. **There is no `"purchase"` checkout type.** `POST /api/v1/checkouts`
   accepts exactly two values for `type`: `"deposit"` and `"withdrawal"`.
   Code that sends `type: "purchase"` will be rejected. "Purchase" is a named
   *flow*, not a `type` value — it's a deposit checkout that also carries
   `line_items`. See [Checkouts](#checkouts) below.
2. **`line_items` has two different shapes and they were conflated.** The
   shape you send when *creating* a checkout is not the shape you receive on
   a *webhook*. Request line items use `name`/`price_cents`/`url`; webhook
   line items use `product_name`/`product_price_cents`/`product_url` instead.
   See [`line_items`: request vs. webhook](#line_items-request-vs-webhook)
   below — this is subtle enough that it's worth reading in full even if you
   think you already know it.
3. **Auth failures return `422`, not `401`.** The older guidance claimed a
   missing or malformed credential returns `401`. It does not. Every auth
   failure on every endpoint is `422` with a JSON `error` field, e.g.
   `{"error": "API key not found."}`. Do not branch on `401` when handling
   Soap auth errors — that code path will never execute.
4. **Soap does not track a customer balance, and there is a second credential
   the old file never mentioned.** There is no balance-read endpoint anywhere
   in the API — you own the ledger (see [Balance](#balance-you-own-the-ledger)).
   Separately, most endpoints use an **API key**, but one specific endpoint
   (`POST /api/v1/device_pings`) uses a different credential, the **client
   secret** — see [Authentication](#authentication).

## Base URLs

| Environment | API | Wallet / checkout | Docs |
|---|---|---|---|
| Sandbox | `https://api-sandbox.paywithsoap.com` | `https://wallet-sandbox.paywithsoap.com` | `https://docs.paywithsoap.com` |
| Production | `https://api.paywithsoap.com` | `https://wallet.paywithsoap.com` | `https://docs.paywithsoap.com` |

## Authentication

Always the same header shape: `Authorization: Bearer <credential>`. Never
omit `Bearer`, and never send the bare credential.

| Credential | Format | Browser-safe? | Used for |
|---|---|---|---|
| API key | `key_...` | No — server-side only | Every endpoint **except** `POST /api/v1/device_pings` |
| Client secret | `secret_...` | Yes | Only `POST /api/v1/device_pings` |

- `POST /api/v1/device_pings` is the one endpoint with permissive CORS, and
  it must be called from the *customer's own device* so Soap can see their
  real IP. That's why it alone takes the browser-safe client secret.
- **Trap:** `GET /api/v1/device_pings/latest_geo_check` sits right next to
  the ping endpoint but uses the **API key**, server-side, not the client
  secret. Mixing these up is a common mistake.
- Auth failures are always `422` — never the status code you might expect
  for an authentication error:

  ```json
  { "error": "API key not found." }
  ```

  ```json
  { "error": "Client secret not found." }
  ```

- **Don't confuse the business client secret with the per-checkout
  `client_secret`.** `POST /api/v1/checkouts` also returns a `client_secret`
  field in its response body, used in the wallet URL as
  `?clientSecret=...`. That value identifies one checkout session for the
  wallet UI — it is not an `Authorization` credential and has nothing to do
  with the business client secret above.

Full detail: [`/api-reference/introduction`](/api-reference/introduction#authentication).

## The complete public API

There is nothing beyond this list. In particular: **no balance endpoint**,
**no products-create endpoint** an agent should use, and **no test-webhook
endpoint**. (`GET /api/v1/products` exists on the backend but is deliberately
undocumented — if you need a `product_id`, use an inline dynamic line item
instead, or ask Soap.)

| Method & path | Purpose |
|---|---|
| `POST /api/v1/customers` | Create a customer. Body: `email`, `first_name`, `last_name`, `phone_number`, `date_of_birth`, `internal_id`. |
| `PATCH /api/v1/customers/{id}` | Update a customer. |
| `GET /api/v1/customers/search` | Look up a customer by `email` (or phone). |
| `POST /api/v1/checkouts` | Create a checkout session. See [Checkouts](#checkouts). |
| `GET /api/v1/charges/{id}` | Retrieve a charge by ID. |
| `POST /api/v1/charges/{id}/refund` | Refund a succeeded NMI card charge in full. No request body — the entire amount is refunded. Returns the same charge shape as GET with `status: "refunded"`. Fails 422 before the charge settles (daily batch, typically next business day). |
| `POST /api/v1/kyc/upsert` | Create/update KYC data. Body: `customer_id`, `first_name`, `last_name`, `email`, `phone_number`, `date_of_birth`, `address_line_1`, `address_line_2`, `city`, `state`, `postal_code`, `country`, `provider`, `verified`, `last_four_ssn`. |
| `POST /api/v1/device_pings` | Record a geolocation ping. Body: `latitude`, `longitude`, `customer_id`. **Client secret. Called from the customer's device.** |
| `GET /api/v1/device_pings/latest_geo_check` | Latest geo check result for a customer. **API key. Server-side.** |

Detail pages: [customers](/api-reference/api-v1/customers/create),
[search](/api-reference/api-v1/customers/search),
[checkouts](/api-reference/api-v1/checkouts/create),
[charges](/api-reference/api-v1/charges/show),
[refund](/api-reference/api-v1/charges/refund),
[KYC](/api-reference/api-v1/kyc/upsert),
[device pings](/api-reference/api-v1/device_pings/create),
[latest geo check](/api-reference/api-v1/device_pings/latest_geo_check).

## Entities: checkout, charge, review

Three separate entities. Do not conflate them.

| Entity | What it is | Id |
|---|---|---|
| Checkout | The session the customer pays in. Also called a *checkout session* — same thing. | `chk_...` |
| Charge | One payment attempt against that checkout, with its own status. | `ch_...` |
| Review | An optional manual approval that pauses a checkout **before** any charge exists. | `cpilr_...` |

**A checkout has one or more charges — one per attempt.** When an attempt
fails, the customer returns to the payment form and their next submission
creates a *new charge with a new `ch_` id*. A declined card followed by a
successful second method leaves two charges on one checkout.

> **Key your ledger on `data.charge.id`, never on the checkout id.** The
> checkout id is not unique per payment; using it as an idempotency or
> reconciliation key will mis-book retries. This is the single most common
> modelling mistake in a Soap integration.

**Most `checkout.*` webhooks are charge events, not checkout events.** They
report a charge transition and carry a `charge` object. Only
`checkout.expired` and `checkout.terminally_failed` describe the checkout
itself, and neither carries a `charge`. Customer events
(`customer.kyc.created`, `customer.restricted`, `customer.unrestricted`)
are neither — no `charge`, `line_items`, or `subscription`. So
`checkout.succeeded` means *the charge succeeded*.

Charge statuses and the transitions between them:

| From | Can move to |
|---|---|
| `created` | `succeeded`, `failed`, `pending`, `held` |
| `pending` | `succeeded`, `failed`, `cancelled` |
| `held` | `succeeded`, `failed`, `pending` |
| `succeeded` | `returned`, `failed`, `voided`, `refunded` |

`failed`, `returned`, `cancelled` and `refunded` are terminal. `succeeded` is
**not** terminal — a settled charge can later be reversed, so you can receive
`checkout.failed` with `from_status: "succeeded"` for money you already
credited.

One status emits **no webhook at all**: `created` (initial state).
`cancelled` has an opt-in event, [`checkout.cancelled`](/api-reference/api-v1/webhooks/checkout-cancelled),
but it is **payout-only and processor-specific** — most accounts will never
receive it. Ask Soap whether it applies before writing a handler.
`refunded` has the opt-in `checkout.refunded`, off by default, produced by
`POST /api/v1/charges/{id}/refund` or a dashboard refund.

Reviews: created **before** the charge exists, so during a review there is no
charge and no charge-based webhook for that checkout. On approval Soap creates
the charge and places the hold (`checkout.hold`). On decline **no charge is
ever created**, so neither `checkout.failed` nor `checkout.release_hold` fires
— only `checkout.review.declined` and `checkout.terminally_failed`, both off by
default. Review is withdrawal-only today, is off by default, and is resolved
manually in the dashboard with no timeout and no public endpoint.

Full detail: [Checkouts, Charges and Reviews](/api-reference/lifecycles).

## Checkouts

`POST /api/v1/checkouts` — `type` accepts exactly two values,
`"deposit"` and `"withdrawal"`. **There is no `"purchase"` and no
`"subscription"` value.** Every named flow below is a combination of `type`
plus other parameters, not a distinct `type`:

| Flow | Parameters |
|---|---|
| Deposit | `type: "deposit"` |
| Purchase | `type: "deposit"` + `line_items` |
| Subscription | `type: "deposit"` + `line_items` + `subscription_data` |
| Balance withdrawal | `type: "withdrawal"` + `balance_amount_cents` |
| Preset withdrawal | `type: "withdrawal"` + `fixed_amount_cents` |

Other parameters:

- `experience` — `"web"`, `"webview"`, or `"iframe"`. `"iframe"` is required
  for Apple Pay to work when the checkout is embedded in an `<iframe>`. See
  [Embedded Checkout](/get_started/embedded-checkout).
- `return_url` — where Soap redirects the customer after completion.

Subscription-specific: `subscription_data.interval` is one of `day`, `week`,
`month`, `year`, plus `interval_count`. Quarterly billing is
`interval: "month"`, `interval_count: 3`. Subscription checkouts forbid
`balance_amount_cents` and `fixed_amount_cents`.

Response shape:

```json
{
  "url": "https://wallet-sandbox.paywithsoap.com?clientSecret=HMC3VTosbzJd7cLsoP3geC5QmSz8jqF6",
  "id": "chk_HBq6ExeoRDMPFCCabR8n1h7S1towds7K",
  "client_secret": "HMC3VTosbzJd7cLsoP3geC5QmSz8jqF6",
  "line_items": [],
  "line_items_total_amount_cents": null,
  "balance_amount_cents": null,
  "type": "deposit",
  "fixed_amount_cents": null,
  "experience": null
}
```

`fixed_amount_cents` is always present and is `null` unless your account is
configured for fixed-amount flows. `subscription` is the opposite: the key is
omitted entirely for non-subscription checkouts rather than returned as `null`,
so test for its presence rather than comparing it to `null`. This differs from
webhook payloads, where `data.subscription` is always present and is `null` when
there is no subscription.

Redirect the customer to `url`, or embed it in `<iframe allow="payment">`.

**Note:** Which parameter combinations an account can use is governed by
per-account checkout flow configuration, not a universal rule. An
unsupported combination returns `422` with a `hint` field.

Detail pages: [create](/api-reference/api-v1/checkouts/create),
[balance withdrawal](/api-reference/api-v1/checkouts/balance-withdrawal),
[preset withdrawal](/api-reference/api-v1/checkouts/preset-withdrawal),
[subscription](/api-reference/api-v1/checkouts/subscription-checkout).

### `line_items`: request vs. webhook

These are two genuinely different shapes. Conflating them is the exact
mistake in prior generated guidance — it isn't that the old
`name`/`price_cents` shape was fabricated, it's that it's only half the
picture and was never labeled as request-only.

**On the request** to `POST /api/v1/checkouts`, each element of `line_items`
is *either*:

- an existing product reference — `product_id` + `quantity`, or
- an inline dynamic product — `name` + `price_cents` + `quantity`, plus
  optional `sku`, `url`, `dedup`.

```json
{
  "customer_id": "cus_vi57KegYgcRqcGHqip8q6UZiqtrwMT870",
  "type": "deposit",
  "line_items": [
    {
      "name": "Gold Coins Pack",
      "price_cents": 999,
      "quantity": 1,
      "sku": "gold-coins-1000",
      "url": "https://example.com/products/gold-coins.png",
      "dedup": true
    }
  ]
}
```

When `dedup: true`, `sku` is required, and Soap reuses an existing product
for your business when both `sku` and `price_cents` match; otherwise it
creates a new one.

**On webhook payloads**, each element of `data.line_items` is a different,
richer, 7-field *read* shape — note `product_name` / `product_url` /
`product_price_cents`, not `name` / `url` / `price_cents`:

```json
{
  "product_id": "prod_8KpQmZ3xRyVnTsUcWjLaGdBh",
  "quantity": 2,
  "total_amount_cents": 5000,
  "product_name": "Gold Coins Pack",
  "product_url": "https://example.com/products/gold-coins",
  "product_price_cents": 2500,
  "sku": "gold-coins-1000"
}
```

Never write webhook-parsing code that reads `name`, `price_cents`, or `url`
off `data.line_items` — those keys don't exist there.

## Webhooks

Every delivery is a JSON object with wire key order `event_id`, `data`,
`type` — `data` comes before `type`.

```json
{
  "event_id": "ev_tDaWu5aTVa2kbvDjGe55rxZpaMEmVFWB",
  "data": { "...": "shape depends on event type" },
  "type": "checkout.succeeded"
}
```

- `data.type` is the **checkout type** (`"deposit"` / `"withdrawal"`), not
  the event type — easy to confuse with the top-level `type`.
- `data.subscription` is present on **every** event; `null` when the
  checkout isn't a subscription.
- Charge-based events carry `data.charge` with `id`, `amount_cents`,
  `transaction_type` (`"credit"` for deposits, `"debit"` for withdrawals),
  `status`, `from_status`, `failure_code`, `failure_message`.

### Signature verification

Two headers carry the identical signature value:

| Header | Notes |
|---|---|
| `SOAP-WEBHOOK-SIGNATURE` | Canonical. |
| `SOAP_SIGNATURE` | Legacy alias, same value. |

Format: `t=<unix timestamp>,v1=<hex HMAC-SHA256 digest>`. The signed message
is `"<timestamp>.<raw_request_body>"`, keyed with your signing secret
(`wss_...`).

**Verify against the raw request body.** If your framework parses JSON
before your handler runs and you re-serialize it to check the signature,
verification will fail — key order and whitespace won't match what Soap
sent. In Express, mount the webhook route with
`express.raw({ type: 'application/json' })` instead of the global JSON
parser, and verify against `req.body` as a raw `Buffer`/string.

### Delivery, retries, idempotency

- 10 second timeout per attempt; up to 5 total attempts for events that
  fail or time out.
- Each attempt gets a fresh timestamp and therefore a fresh signature —
  don't assume retries are byte-identical.
- `event_id` is stable across all attempts of the same logical event. Use it
  as your idempotency key.
- `checkout.hold` is the one exception: it's synchronous and **never
  retried**.

### Event catalog

**Always sent (4):** `checkout.succeeded`, `checkout.hold`,
`checkout.release_hold`, `checkout.returned`

**Off by default (13)** — never delivered until enabled in
Dashboard → Developers: `checkout.pending`, `checkout.failed`,
`checkout.voided`, `checkout.refunded`, `checkout.cancelled`,
`checkout.expired`, `checkout.terminally_failed`,
`checkout.review.created`, `checkout.review.approved`,
`checkout.review.declined`, `customer.kyc.created`,
`customer.restricted`, `customer.unrestricted`.

`checkout.cancelled` is payout-only and processor-specific — ask Soap
whether it applies before writing a handler. Customer events have no
`charge`, `line_items`, or `subscription`.

**Warning:** If you write a handler for one of the 13 off-by-default events,
it will silently never fire until you enable it. This is a common source of
"my webhook handler doesn't work" bug reports.

Event detail pages:
[`checkout.succeeded`](/api-reference/api-v1/webhooks/checkout-succeeded),
[`checkout.hold`](/api-reference/api-v1/webhooks/checkout-hold),
[`checkout.release_hold`](/api-reference/api-v1/webhooks/checkout-release-hold),
[`checkout.returned`](/api-reference/api-v1/webhooks/checkout-returned),
[`checkout.cancelled`](/api-reference/api-v1/webhooks/checkout-cancelled),
[`customer.kyc.created`](/api-reference/api-v1/webhooks/customer-kyc-created),
[`customer.restricted`](/api-reference/api-v1/webhooks/customer-restricted),
[`customer.unrestricted`](/api-reference/api-v1/webhooks/customer-unrestricted),
[full event catalog](/api-reference/api-v1/webhooks/webhooks),
[setup guide](/api-reference/api-v1/webhooks/setup).

## Balance: you own the ledger

There is no balance-read endpoint, and none could be accurate — you may
credit or debit customers for reasons Soap never sees (bonuses, promotions,
gameplay, manual corrections).

`balance_amount_cents` on a withdrawal checkout is a figure **you assert**
at checkout-creation time. Soap stores it on that one checkout and enforces
it as the withdrawal ceiling; it is not a balance Soap tracks or validates
against anything else.

Compute it yourself: take the customer's balance from your own ledger, minus
any amount already held for withdrawals currently in flight (checkouts where
you've received `checkout.hold` but no terminal event yet).

## The hold lifecycle (highest-consequence rule)

Withdrawals use a synchronous hold handshake to stop a customer from
spending money that's already being cashed out. Getting this wrong is the
easiest way to double-debit a real customer.

1. `checkout.hold` arrives, synchronously, **never retried**, 10s timeout.
2. Respond **2xx** → customer has funds, withdrawal proceeds. Respond
   **non-2xx** → insufficient funds, withdrawal does not proceed.
3. **Debit the customer when you respond 2xx to `checkout.hold`.**
4. The later `checkout.succeeded` for that same checkout **must be a no-op**
   if you already debited on hold. Debiting on both is the double-debit bug.
5. Several different events can mean "credit the money back", and they are not
   interchangeable. Handle **all** of them, keyed on your own record of what
   you debited:
   - `checkout.release_hold` — always sent. The charge went straight from
     held to failed.
   - `checkout.failed` — **off by default, and the one that strands money.**
     An accepted payout that fails later goes held → pending → failed, so
     because the charge was *pending* rather than *held* immediately before
     failing, Soap sends this instead of `checkout.release_hold`. This is the
     normal failure shape for asynchronous payout rails. Tell the user to
     enable it in Dashboard → Developers before going live.
   - `checkout.returned` — always sent. A settled payout bounced.
   - `checkout.expired` — off by default, and **carries no `charge` object**
     (only `id`, `expired_at`, `type`, `customer.id`, `subscription`). Do not
     read `charge.amount_cents` here; it will throw.
   - `checkout.cancelled` — off by default, **payout-only and processor-
     specific**. Most accounts will never receive it. Ask Soap whether it
     applies before handling it as a credit-back. The charge typically
     moves pending → cancelled; if you already debited on hold, credit back
     the same way as `checkout.failed` with `from_status: "pending"`.
6. Because `checkout.expired` has no charge, store the amount you debited
   against the checkout id when you handle `checkout.hold` — a map, not a set
   of ids — and credit back from your own record. Delete the record as you
   credit so two terminal events can't refund twice.
7. Deposits: credit on `checkout.succeeded`. `checkout.returned` (always
   sent) means a previously-succeeded payment bounced afterward (e.g. an ACH
   return) — debit it back. `checkout.refunded` (off by default) means the
   payment was deliberately refunded via the dashboard or the refund
   endpoint — debit it back the same way; if you don't enable it, poll the
   charge instead.

Full walkthrough with a sequence diagram and a complete handler:
[Balance Withdrawal → The Hold Lifecycle](/api-reference/api-v1/checkouts/balance-withdrawal#the-hold-lifecycle).

## Testing reality

Sandbox test card `4111-1111-1111-1111`, CVV `999`. There is **no**
dashboard "send test webhook" button — the only way to exercise a webhook
handler is to complete a real checkout against the sandbox and let Soap
deliver the resulting event. See [Testing](/api-reference/api-v1/testing) and
[Receiving Webhooks](/api-reference/api-v1/webhooks/setup#minimal-express-handler).

## Onboarding

Invitation-based. There is no public self-serve signup — see
[API Introduction](/api-reference/introduction#getting-started).

## Minimal end-to-end integration

1. **Create a customer** — `POST /api/v1/customers` with at least `email`,
   `first_name`, `last_name`. Store the returned `id` (`cus_...`).
2. **Create a checkout** — `POST /api/v1/checkouts` with `customer_id` and
   `type: "deposit"` (add `line_items` for a purchase, plus
   `subscription_data` for a subscription; use `type: "withdrawal"` with
   `balance_amount_cents` or `fixed_amount_cents` for withdrawals).
3. **Redirect the customer** to the response's `url` (or embed it in
   `<iframe allow="payment">`).
4. **Receive and verify the webhook** — read the raw body, check
   `SOAP-WEBHOOK-SIGNATURE` (or `SOAP_SIGNATURE`) against
   `"<timestamp>.<raw_body>"` using your `wss_...` signing secret. Reject on
   mismatch.
5. **Apply the ledger update keyed on `event_id`** — check
   `processedEventIds` first, then act on `type` (crediting on
   `checkout.succeeded` for deposits, following the
   [hold lifecycle](#the-hold-lifecycle-highest-consequence-rule) for
   withdrawals), then record `event_id` as processed. Never apply the same
   `event_id` twice.

```javascript
// 1–2: create customer, then checkout (server-side, API key)
const customer = await fetch('https://api-sandbox.paywithsoap.com/api/v1/customers', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.SOAP_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, first_name, last_name })
}).then(r => r.json());

const checkout = await fetch('https://api-sandbox.paywithsoap.com/api/v1/checkouts', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.SOAP_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ customer_id: customer.id, type: 'deposit', return_url: 'https://myapp.com/done' })
}).then(r => r.json());

// 3: redirect the customer to checkout.url

// 4–5: webhook endpoint
app.post('/webhooks/soap', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body.toString('utf8');
  const sig = req.headers['soap-webhook-signature'];
  if (!verifySignature(payload, sig, process.env.SOAP_WEBHOOK_SECRET)) {
    return res.status(422).send('Invalid signature');
  }

  const event = JSON.parse(payload);
  if (alreadyProcessed(event.event_id)) return res.sendStatus(200);

  if (event.type === 'checkout.succeeded') {
    applyLedgerUpdate(event.data);
  }
  markProcessed(event.event_id);
  res.sendStatus(200);
});
```

For the full signature-verification helper and the withdrawal hold handler,
see [Webhooks Overview](/api-reference/api-v1/webhooks/webhooks) and
[Balance Withdrawal](/api-reference/api-v1/checkouts/balance-withdrawal).
