> For the complete documentation index, see [llms.txt](https://support.swisspay.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://support.swisspay.ai/api-reference/payments.md).

# Payments

Charge a card and read back the result. One unified shape regardless of the underlying provider.

All endpoints are under `https://app.swisspay.ai/api/v1/` and authenticate with a Bearer API key:

```
Authorization: Bearer sk_test_...
```

Keys follow your workspace environment: live workspaces issue `sk_live_...` keys, sandbox workspaces issue `sk_test_...` keys. A key only works while its mode matches the workspace environment (`key_mode_mismatch` otherwise).

All requests and responses are JSON. Amounts are integers in the currency's **minor units** (e.g. `2999` is `$29.99`). Timestamps (`created`) are Unix seconds.

> Card data sanitization
>
> Swisspay never stores or returns the full card number (PAN) or CVC. After a payment is created, only the brand and last four digits are retained.

## Create a payment

```
POST /api/v1/payments
```

Charges a card synchronously and returns the resulting `Payment`.

### Headers

| Header            | Required | Notes                                                                      |
| ----------------- | -------- | -------------------------------------------------------------------------- |
| `Authorization`   | yes      | `Bearer sk_test_...`                                                       |
| `Content-Type`    | yes      | `application/json`                                                         |
| `Idempotency-Key` | yes      | Any client-chosen string up to 255 chars. See [Idempotency](#idempotency). |

### Request body

| Field                        | Type             | Required    | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| ---------------------------- | ---------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `amount`                     | integer          | yes         | Charge amount in minor units. Must be greater than `0`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `currency`                   | string           | yes         | ISO 4217 three-letter currency code, uppercase (e.g. `USD`, `EUR`, `CHF`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `customer`                   | string or object | no          | A `cus_...` id to attach an existing customer to this payment, **or** a customer object matching the [Create a customer](/api-reference/customers.md#create-a-customer) body. If an object, the customer is created in the same request.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `billing_address`            | object           | no          | Billing address for **this** payment, forwarded to the provider as `billingAddress` (used for address verification). Same flat shape as the customer's [`billing_address`](/api-reference/customers.md#the-customer-object): `line1`, `line2`, `city`, `postal_code`, `state`, `country` (ISO-3166 alpha-2). All fields optional. When a customer is also attached, this **overrides** the customer's billing address for this transaction only -- the stored customer record is not changed. A malformed or incomplete address is dropped, never failing the payment.                                                                                                                                                                                                                                              |
| `delivery_address`           | object           | no          | Shipping/delivery address for **this** payment, forwarded to the provider as `deliveryAddress`. Same shape and override semantics as `billing_address`. (Commonly called the "shipping address.")                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `reference`                  | string           | no          | Your own identifier for this payment (e.g. order id). Echoed back on the `Payment`. Up to 80 chars.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `shopper_statement`          | string           | no          | The text the shopper sees on their bank or card statement for this payment. Echoed back on the `Payment`. Swisspay sanitises and trims the value before sending it for processing: characters outside `A-Z a-z 0-9 . -` become spaces (accented Latin is transliterated, so `Zurich` becomes `Zurich`), and the value is trimmed to fit the 22-character statement limit *including the prefix that appears on the shopper's statement* (`PAYPAL*` on wallet payments, `PP*` on Amex) -- backing off to a whole word where that still uses most of the room. So the descriptor on the statement may be shorter than the value you send; the value you sent is echoed back on the `Payment` unchanged.                                                                                                               |
| `capture_method`             | string           | no          | `"automatic"` (default) charges the card immediately. `"manual"` only places a hold -- the payment comes back `authorized` and you later [capture or void](#manual-capture-authorize-then-capture) it. Cannot be combined with a merchant-initiated charge.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `payment_method`             | object           | conditional | The card to charge. Required unless `stored_payment_method` is provided. Exactly one of the two must be present. See below.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `payment_method.type`        | string           | yes         | `"card"` to charge card details you supply, `"card_fields"` to have the card collected securely in the browser (see [PayPal card fields](#paypal-card-fields)), or `"paypal"` to have the customer pay from their PayPal account (see [PayPal wallet](#paypal-wallet)). `card_fields` and `paypal` must not carry any card details.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `line_items`                 | array            | no          | Basket detail. Each item needs `name` and `unit_amount` (minor units), and may carry `quantity` (default `1`), `tax`, `sku`, `description`, `url`, `image_url`, `upc` and `category` (`PHYSICAL_GOODS`, `DIGITAL_GOODS` or `DONATION`), plus the Level 3 fields `commodity_code` and `unit_of_measure`. Items must total `amount` less `tax_amount`, `shipping_amount` and `handling_amount`, plus `discount_amount`, or the request is rejected with `422`. Omitted, a single line covering the whole amount is sent for you. See [Line items](#line-items).                                                                                                                                                                                                                                                       |
| `line_items[].tax`           | integer          | no          | Tax on **one unit** of this item, in minor units. When any item carries it, the per-item figures become the order's tax and must equal `tax_amount` if you also send that -- see [Line items](#line-items).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `line_items[].upc`           | object           | no          | Universal product code, as `{"type": "UPC-A", "code": "012345678905"}`. `code` is 6-17 digits. Omit `type` and `UPC-A` is assumed for a 12-digit code; a shorter code with no recognised type is dropped rather than mislabelled.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `tax_amount`                 | integer          | no          | Tax included in `amount`, in minor units. Sent as Level 2 interchange data. Ignored in favour of `line_items[].tax` when the items carry tax.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `shipping_amount`            | integer          | no          | Shipping included in `amount`, in minor units.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `handling_amount`            | integer          | no          | Handling included in `amount`, in minor units.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `discount_amount`            | integer          | no          | Discount already deducted from `amount`, in minor units.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `duty_amount`                | integer          | no          | Duty included in `amount`, in minor units. Level 3 only.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `invoice_id`                 | string           | no          | Your invoice number. Sent as Level 2 interchange data.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `description`                | string           | no          | What the payment is for. Used as the line name when no `line_items` are given.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `ships_from_postal_code`     | string           | no          | Where the goods ship from. Level 3 only.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `stc`                        | object           | no          | PayPal only. Risk data about the buyer, sent to PayPal as a transaction context before the payment is processed. Required by PayPal risk policy for some industries. See [Set Transaction Context (STC)](#set-transaction-context-stc).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `payment_method.number`      | string           | yes         | Card number (PAN). Test cards only.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `payment_method.exp_month`   | integer          | yes         | Expiry month, `1`-`12`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `payment_method.exp_year`    | integer          | yes         | Four-digit expiry year. Must not be in the past.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `payment_method.cvc`         | string           | yes         | Card verification code. Never stored.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `payment_method.holder_name` | string           | no          | Cardholder name as printed on the card.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `save_payment_method`        | boolean          | no          | Whether to tokenize the card for later reuse. **Omitted => follows the account default**, which saves the card whenever a `customer` is attached (configurable via the `store_payment_method_default` account setting). `true` forces saving and requires a `customer` -- an explicit `true` with no customer returns `422 validation_error`. `false` opts this payment out. When a card is saved its `pm_...` id is returned in `payment_method_id`. A `null` there on a successful payment does **not** confirm the card was saved: on a small minority of payments the save is accepted and then never produces a saved card. Confirm against [`GET /api/v1/customers/{id}/payment_methods`](/api-reference/payment-methods.md#list-a-customers-saved-cards) and re-collect if nothing appears. See Saved cards. |
| `stored_payment_method`      | string           | conditional | Charge a previously saved card by its `pm_...` id instead of submitting raw card details. Exactly one of `payment_method` or `stored_payment_method` is required (sending both, or neither, returns `422`). The saved card must belong to the `customer` on the request. See Saved cards.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `use_last_payment_method`    | boolean          | no          | Charge the customer's most recently saved active card without supplying a `stored_payment_method`. Requires a `customer`; cannot be combined with `stored_payment_method`. Returns `422` if the customer has no saved card. Picks the **newest** saved card -- for a subscription pinned to one card, pass the explicit `pm_...` instead.                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `merchant_initiated`         | boolean          | no          | Applies only when charging a saved card (`stored_payment_method` or `use_last_payment_method`). `false` (default) -- on-session charge: the shopper is present at checkout and a 3DS challenge may be triggered. `true` -- off-session merchant-initiated charge (e.g. subscription rebill): no 3DS challenge is possible. See Saved cards.                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `recurring_processing_model` | string           | no          | How the card network should classify a stored-card charge: `card_on_file` (default), `subscription` (fixed amount, fixed schedule), or `unscheduled_card_on_file` (variable amount / irregular merchant-initiated charge). Only valid when a card is being saved (a `customer` is attached and tokenization is on) or a `stored_payment_method` is being charged. When charging a saved card, defaults to the model the card was saved with.                                                                                                                                                                                                                                                                                                                                                                        |
| `renews_payment`             | string           | no          | The `pay_...` id of an earlier payment that this charge renews -- the first charge of a subscription, or any renewal since. Requires charging a saved card (`stored_payment_method` or `use_last_payment_method`), and must name a payment of the same customer in the same workspace; otherwise `422 validation_error` with `renews_payment not found`, `renews_payment requires charging a stored_payment_method`, or `renews_payment must belong to the same customer as the stored_payment_method`. Recorded on the payment and returned as `renews_payment`. Omit it on a charge that is not a renewal. If you run a Shopify store, this is what makes a renewal appear there as an order -- see Shopify orders -> Subscription renewals.                                                                      |
| `shopper_ip`                 | string           | no          | The end shopper's IP address. Forwarded to the underlying provider's fraud engine. Send this when calling the API server-to-server; if omitted on a direct browser-to-API call, the connecting IP is used as a fallback.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `browser_info`               | object           | no          | Context about the shopper's browser, collected on your checkout page -- `user_agent` (`navigator.userAgent`), `language` (IETF BCP 47, e.g. `en-US`), `screen_height`, `screen_width`, `color_depth`, `time_zone_offset`, `java_enabled`. All fields optional, and no authentication step needs them. Sending it (or a `browser_info_token`) marks the request as coming from the shopper's own browser, which is what allows the connecting-IP fallback described under [the shopper's IP address](#the-shoppers-ip-address). `language` also doubles as the shopper locale on Swisspay-hosted pages when the `customer` has no `locale`.                                                                                                                                                                          |
| `browser_info_token`         | string           | no          | A single opaque token produced by the Swisspay collector script (`Swisspay.collectBrowserInfo()`) -- see [Collecting device data](#collecting-device-data). Decoded server-side into the `browser_info` fields above. When both this and an inline `browser_info` object are sent, inline fields win per field. A malformed token is ignored, never failing the payment.                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `device_fingerprint`         | string           | no          | The device-data session id returned by the Swisspay collector on your checkout page -- see [Collecting device data](#collecting-device-data). Forwarded with the payment as a risk signal; has no effect if omitted. 1-68 characters from `A-Z a-z 0-9 - { } ( ) ,` -- a value outside that is dropped rather than sent, so the payment still goes through.                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `authentication`             | string           | no          | Authentication preference for this payment. The only accepted value is `"automatic"`, which is also the default when the field is omitted. `"automatic"` means Swisspay delegates the decision to the connection's configured 3-D Secure setting. Other values (`force_3ds`, `prefer_no_challenge`, `force_challenge`) are reserved and will be rejected with `422`.                                                                                                                                                                                                                                                                                                                                                                                                                                                |

### The shopper's IP address

`shopper_ip` is the strongest server-side risk signal you can send, so send it explicitly whenever you call the API from your own server.

When you omit it, Swisspay can fall back to the address the request arrived from. That address is the shopper's only when the shopper's own browser made the call -- on a server-to-server integration it is your server, and scoring every one of your transactions against a single origin is worse than sending no signal at all. The fallback is therefore used only when the request also carries browser-measured `browser_info` (or a `browser_info_token`), which is what identifies the caller as a real browser; without it, no IP is sent.

The address must also be a single routable one. Anything else is dropped rather than forwarded, and never fails the payment:

* a proxy chain such as `203.0.113.7, 70.41.3.18` -- send one address, not a raw `X-Forwarded-For` value
* a private, loopback, link-local, or unspecified address (e.g. `10.0.0.5`, `127.0.0.1`, `::`)
* a CIDR block such as `203.0.113.0/24`, or any value that is not an IP address at all

### Notes on card payments

A few behaviours worth knowing before you go live:

* `success_url` / `failure_url` are ignored on a card payment **unless** the connection's 3-D Secure setting is **Always**, in which case the buyer authenticates on an approval page and both become required; see 3-D Secure. They are always required for a `paypal` wallet payment.
* `save_payment_method: true` is honoured: the instrument is stored and the saved card's `pm_...` id comes back in `payment_method_id` -- usually, but not always; see the next point. This applies to both a card and a PayPal wallet -- a saved wallet is the buyer's PayPal account rather than a card, so it has no `last4` or expiry. `save_payment_method: false` opts out as normal.
* **A save request can be accepted and then quietly produce nothing.** On a minority of payments Swisspay's processing acknowledges `save_payment_method: true`, takes the money, and never stores the card. The payment succeeds, `payment_method_id` is `null`, and no card is stored -- there is no later event that completes it, so waiting does not help. Confirm the instrument exists with [`GET /api/v1/customers/{id}/payment_methods`](/api-reference/payment-methods.md#list-a-customers-saved-cards) before telling the customer their card is on file, and re-collect if it is empty. It is per-attempt rather than sticky: the same customer and card save normally on the next try.
* On the flows where the buyer completes the payment away from your server -- a [PayPal wallet](#paypal-wallet), or `payment_method.type: "card_fields"` -- the instrument is saved when you [confirm](#confirming-a-card-fields-payment) the payment, not when you create it. The initial response therefore carries `payment_method_id: null` and `status: "requires_action"`; the saved id appears on the confirm response.
* `recurring_processing_model` sets the card network's classification of the stored credential (`subscription` => recurring, otherwise unscheduled).
* `stored_payment_method` and `use_last_payment_method` charge the saved instrument. The saved card must belong to the `customer` on the request and to the same workspace -- another workspace's saved card is not visible and returns `422` with `stored_payment_method not found`.
* A **live** payment is refused with `422 validation_error` while your account is not cleared for live processing, and the `code` tells you which of two situations you are in. Until your account has been activated for live payments -- a manual step that completes after onboarding checks -- the code is `processing_not_enabled` and the message reads `PayPal processing has not been enabled for this merchant`. If an activated account is later suspended, restricted or closed, the code is `processing_not_active` and the message names the account's current status; processing resumes on its own once the account is restored, with nothing to change in your integration. Both refusals apply to every call that moves money to you: [creating](#create-a-payment) a payment, [confirming](#confirming-a-card-fields-payment) one, and [capturing](#capture-a-payment) an authorization. So neither an order started just before a suspension nor an authorization held from before it can be settled during one -- the payment keeps the status it already had, `requires_action` or `authorized` respectively, and becomes chargeable again by itself once the account is restored. Voids and refunds are never refused on these grounds: you can always [release](#void-a-payment) a hold you cannot capture, rather than leave the shopper's funds held until the authorization expires, and you can always refund. **Test-mode payments are exempt**, so you can build and exercise your integration in the sandbox while activation is pending.
* If your account was set up with a settlement hold that is not yet active on the account, new payments are declined as `failed` with an account-restricted error rather than settling. This is resolved on your account, not in your request -- contact Swisspay, and retry once the account's settlement setting has been reconciled. Note this affects **new sales only**: a capture, void, or refund on a payment already authorized continues to settle.

A refund always uses the same route the original payment was charged through -- there is nothing to choose on the [refunds API](/api-reference/refunds.md).

## Set Transaction Context (STC)

PayPal's risk policy requires merchants in certain industries to send a **transaction context** -- a short profile of the buyer as they exist on *your* platform -- before a payment is processed. PayPal uses it for a pre-transaction risk and compliance evaluation. Send it as an `stc` object on [Create a payment](#create-a-payment).

Swisspay sends the context to PayPal **before** the call that takes the money, and never derives the values: every field describes the buyer's relationship with your platform, which only you can describe. See Set Transaction Context for the full field reference, the per-industry packs and worked examples.

```json
{
  "amount": 2999,
  "currency": "USD",
  "payment_method": {
    "type": "card",
    "number": "4111111111111111",
    "exp_month": 3,
    "exp_year": 2030,
    "cvc": "737"
  },
  "stc": {
    "sender_account_id": "A12345N343",
    "sender_first_name": "John",
    "sender_last_name": "Smith",
    "sender_email": "john@example.com",
    "sender_phone": "(042) 1123 4567",
    "sender_country_code": "US",
    "sender_create_date": "2012-12-09T19:14:55.277-0:00",
    "highrisk_txn_flag": "0",
    "vertical": "Retail"
  }
}
```

Which fields are required depends on your merchant category code (MCC), which selects one of four field packs. Three keys steer the call rather than travelling in it:

| Key              | Type    | Description                                                                                                                                                                                                                                                  |
| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `tracking_id`    | string  | Your own unique id for this transaction, 1-100 characters. Defaults to the request's `Idempotency-Key`, or a generated value. Echoed back so you can correlate the context with the payment.                                                                 |
| `guest_checkout` | boolean | `true` when the buyer has no account on your platform. The whole sender-profile block then becomes optional -- and an *incomplete* profile is dropped rather than partly sent, because a half-filled profile claims to describe a buyer you cannot describe. |
| `intangible`     | boolean | `true` when the sale is of intangible goods, which makes `dg_delivery_method` required on the Retail pack.                                                                                                                                                   |

**Errors.** If you ask for a context that cannot be built, the payment is refused with `422` and no money moves:

* your MCC is not one PayPal has mapped to a field pack -- there is deliberately no fallback pack, because a context filed under the wrong industry is worse than none;
* a field the pack requires is missing or empty;
* a value is outside the set the pack allows (for example `highrisk_txn_flag` accepts only `"0"` or `"1"`).

Omit `stc` entirely and no context is sent, and the payment behaves exactly as it did before. Sending `stc` to a non-PayPal provider is refused with `422` rather than ignored.

## Address verification (AVS)

The billing address on a card payment is checked by the card issuer against the address it holds for that card. A match is one of the strongest approval signals available, and sending no address at all means no verification happens -- so it is worth supplying on every card charge.

**Where the address comes from.** Either the `billing_address` on the payment, or the [`billing_address`](/api-reference/customers.md#the-customer-object) stored on the attached customer. A payment-level address applies to that transaction only and does not change the stored customer record; when the payment carries none, the customer's stored address is used.

**`country` is the one field the address cannot travel without.** An address carrying no country, or a country that is not an ISO-3166 alpha-2 code, is dropped rather than sent -- verification simply does not run for that charge. As with any malformed or incomplete address, this never fails the payment.

**Use postal abbreviations for `state`** -- `ZH`, `CA` rather than `Zurich`, `California`. Address verification matches on that field, so the full name weakens an otherwise good match.

**A mismatch does not fail the payment by default.** The issuer decides whether to approve, and an approved payment whose address disagreed still succeeds. You can opt into voiding those authorisations instead -- see address verification.

**Reading the verdict back.** A payment the issuer answered on carries an `avs` object:

```json
{
  "id": "pay_3kd91mfk20xz",
  "status": "succeeded",
  "avs": { "code": "A", "verdict": "match" }
}
```

`verdict` is the field to branch on, and it has three values:

| `verdict`     | Meaning                                                                                                                        |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `match`       | The issuer verified the address. A partial match -- street or postcode, not both -- is reported here, and the payment is kept. |
| `mismatch`    | The issuer checked and nothing matched. The only verdict the void-on-mismatch setting acts on.                                 |
| `unavailable` | The issuer did not check, cannot check, or asked us to retry. No verification happened, so this is never treated as a failure. |

`code` is the issuer's own letter, passed through unchanged, for reconciling against your own records.

The object is **absent** rather than null when no verdict exists -- no address was sent, or the issuer said nothing. Absent means "nobody checked", which is not the same as `unavailable`, where the issuer was asked and declined to answer.

## Line items

Sending what the customer actually bought is worth doing on every card payment. It feeds the provider's risk scoring, it is what the customer sees in their PayPal activity, and the SKU and product URL are what you argue a dispute with months later. On commercial cards it also qualifies the transaction for Level 2/3 interchange, which is cheaper.

```json
{
  "amount": 2999,
  "currency": "CHF",
  "tax_amount": 199,
  "shipping_amount": 500,
  "line_items": [
    {
      "name": "Alpine Wool Blanket",
      "quantity": 1,
      "unit_amount": 2300,
      "tax": 199,
      "sku": "BLK-001",
      "description": "Merino wool, 150 x 200 cm",
      "url": "https://acme.example/blankets/alpine",
      "image_url": "https://acme.example/blankets/alpine.jpg",
      "upc": { "type": "UPC-A", "code": "012345678905" },
      "category": "PHYSICAL_GOODS",
      "commodity_code": "52121500",
      "unit_of_measure": "ITM"
    }
  ]
}
```

The arithmetic has to balance: items must total `amount` less `tax_amount`, `shipping_amount` and `handling_amount`, plus `discount_amount`. In the example above, `2300 = 2999 - 199 - 500`. A mismatch returns `422` naming both figures rather than failing later at the provider.

#### Product URLs

Send `url` on every item you can. PayPal describes the field as "URL to the purchased item. Visible to buyer and used in buyer experiences" -- the customer opens it from their PayPal activity, and it is how the item is identified if the purchase is disputed later. `image_url` is shown alongside it.

It has to be the page for **that item**, and it has to be an absolute `http(s)` URL:

```json
"url": "https://acme.example/blankets/alpine"
```

A value that is not absolute is dropped rather than forwarded, because the customer who clicks it would get a dead link. That covers a bare host (`www.acme.example/blankets/alpine`), a site-relative path (`/blankets/alpine`), any scheme other than `http`/`https`, and anything over 2048 characters -- a URL cannot be trimmed to fit without pointing somewhere else. `image_url` follows the same rule.

Your storefront is not substituted for a missing item URL. An item you send without a `url` is sent without one: a homepage under the name of a specific product is a link to the wrong page, and the customer is the one who ends up clicking it.

#### Per-item tax

`tax` on an item is the tax on **one unit** of it, and the order's tax is the sum of `tax x quantity` across every line. That total is not independent of `tax_amount` -- it *is* the order's tax as soon as any item carries one, so:

* Send `tax` on the items and omit `tax_amount`, and the items decide the total. They must still total `amount` less that tax: for one unit at `2839` with `tax` `160`, `amount` is `2999`.
* Send both and they must agree, or the request is rejected with `422` naming both figures.
* Send `tax_amount` alone and it stands as the order's tax, which is the simpler option when you do not hold a per-line breakdown.

An item with no `tax` key says nothing about tax; an item with `"tax": 0` states that the line is untaxed, and that zero counts toward the order's total.

Omit `line_items` and a single line covering the whole amount is sent for you, using `description` as its name and `reference` as its SKU. That line names no product, so it is the one case where your own website is used as the URL -- we have nothing more specific to point at. That is thinner detail than a real basket, and thinner than PayPal asks for: send the basket where you have it.

### Level 3 fields

Three per-item fields exist only to qualify the transaction for Level 3 interchange, and they are worth sending if you are selling to businesses on commercial or purchasing cards. All three are optional and none of them affects the arithmetic above.

| Field             | Type    | Notes                                                                                                                                              |
| ----------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tax`             | integer | Tax on **one unit** of this item, in minor units. Should sum, across the basket, to `tax_amount`. A non-integer value is rejected with `422`.      |
| `commodity_code`  | string  | The item's classification code in your buyer's scheme, usually UNSPSC. Up to 12 characters after punctuation is stripped; longer is a `422`.       |
| `unit_of_measure` | string  | What `quantity` counts -- `ITM` (items) if you omit it, or a UN/ECE code such as `KGM`, `MTR`, `HUR`. Same 12-character limit as `commodity_code`. |

Nothing is invented in their absence: omit `tax` and no tax is claimed for the line, omit `commodity_code` and none is sent. A guessed code classifies the purchase wrongly in your buyer's system, which costs the interchange saving it was meant to earn.

Level 2 needs only a purchase identifier and a tax total, and is sent on **every card order**: the identifier is your `invoice_id`, or your `reference`, or -- if you sent neither -- the `Idempotency-Key` from the request. `tax_amount` fills in the tax total when you send it.

Level 2 and Level 3 are card-network data, so neither is sent on a [PayPal wallet](#paypal-wallet) payment -- including Pay Later, and including a charge against a saved PayPal account. There is no card rail to earn the cheaper interchange on: your customer picks how to fund the payment on PayPal's side, after the order is created. Send the fields anyway if it suits your integration to send one shape of request for everything -- they are accepted and validated as documented, and simply not forwarded. Everything else you send still goes: `line_items` in particular reaches PayPal on a wallet order, which is what your customer sees in their PayPal activity and what you argue a dispute from later.

### Collecting device data

Device signals can only be read on the checkout page itself, in the shopper's browser. Swisspay's collector script gathers them and gives you two values to relay with the payment: a **device-data session id**, sent as `device_fingerprint`, and optionally a **browser-info token**, sent as `browser_info_token`.

The collector runs in the shopper's browser, but `POST /api/v1/payments` is authenticated with your **secret** `sk_...` key, which must never reach client-side code. So the flow is two hops: the browser produces the values and hands them to **your** server, and your server makes the authenticated call. Both values are opaque correlation keys, safe to relay through the browser -- neither carries a secret.

**Step 1 -- in the shopper's browser**, collect the values and send them to your own backend (typically alongside the order in the request that kicks off the payment):

```html
<script src="https://app.swisspay.ai/v1/browser-info.js"></script>
<script>
  // Call this as the checkout page loads, not at submit time -- the collector
  // needs time on the page to gather anything worth sending.
  const device_fingerprint = Swisspay.collectPaypalDeviceData({
    // Optional. Defaults to "swisspay_checkout-page". Use
    // "<your account id>_<page>" if you want to tell your own checkout
    // surfaces apart.
    sourceIdentifier: "checkout-page",
    // Set true on a sandbox checkout so test traffic stays out of your live
    // risk profile. Defaults to false.
    sandbox: false,
  })
  const browser_info_token = Swisspay.collectBrowserInfo()
  // Post both to YOUR backend -- not to Swisspay. No Swisspay key in the browser.
</script>
```

**Step 2 -- on your server**, forward them verbatim in the authenticated payments call:

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_42_attempt_1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "payment_method": {
      "type": "card",
      "number": "4111111111111111",
      "exp_month": 3,
      "exp_year": 2030,
      "cvc": "737"
    },
    "device_fingerprint": "<id from step 1>",
    "browser_info_token": "<token from step 1>"
  }'
```

Notes worth knowing before you wire it:

* **Skipping this costs approvals rather than failing anything visibly** -- a payment sent with no device signal is risk-scored without one.
* **Call `collectPaypalDeviceData()` once per page.** Repeat calls return the same id and collect nothing further. One id per checkout is the point: two ids split one buyer's device data into what looks like two different shoppers.
* `Swisspay.collectBrowserInfo()` is synchronous and never throws -- if a signal can't be read it's simply omitted. You can also send an inline `browser_info` object alongside (or instead of) the token; when both are present, inline fields win per field.

## Return URLs (`success_url`, `failure_url`)

| Field         | Type   | Notes                                 |
| ------------- | ------ | ------------------------------------- |
| `success_url` | string | HTTPS URL, no fragment, <= 2048 chars |
| `failure_url` | string | HTTPS URL, no fragment, <= 2048 chars |

Both are **required** on a [`paypal` wallet](#paypal-wallet) payment -- the buyer approves away from your site and needs somewhere to land. On a card payment they are required only when the connection's 3-D Secure setting is **Always**, which sends the buyer to an approval page for authentication -- see 3-D Secure. Otherwise they are ignored.

## `three_d_secure`

Once an authentication outcome is reported, the payment carries it. It appears on the confirm response and on every later `GET /api/v1/payments/:id`:

```json
"three_d_secure": {
  "liability_shift": "POSSIBLE",
  "enrollment_status": "Y",
  "authentication_status": "Y"
}
```

`liability_shift` is the field the money turns on, and **`POSSIBLE` is the success case** -- it is what a card that authenticated successfully returns (`enrollment_status: "Y"` with `authentication_status: "Y"`), and a payment carrying it has the shift. `NO` and `UNKNOWN` both mean no shift was obtained. (`YES` is not part of the documented set; it is accepted and treated as an obtained shift if it ever appears, but do not wait for it.)

`enrollment_status` and `authentication_status` are the directory-server letters (`Y`, `N`, `U`, `A`, `R`, ...).

Store the reported string rather than mapping it to a boolean -- a chargeback is argued months later on exactly what was reported.

The whole object is **absent when no authentication result was reported** -- a payment that ran no 3-D Secure at all, and equally one where the challenge was attempted and nothing came back. Both are the same thing commercially: no liability shift was obtained. In particular a capture can succeed with `liability_shift: "UNKNOWN"` or with no object at all even when the connection forces authentication (**Always**), so a completed payment is not evidence that the authentication you asked for happened. This field is where you check.

## `next_action`

When the response carries `status: "requires_action"`, the body includes a `next_action` object telling you what has to happen in the buyer's browser. Its `type` says which flow you are in.

A [card-fields](#paypal-card-fields) payment returns:

```json
"next_action": {
  "type": "paypal_card_fields",
  "checkout_url": "https://app.swisspay.ai/checkout/pay/3ds_..."
}
```

Send the customer to `checkout_url` -- a hosted checkout page Swisspay serves for this one payment. Swisspay collects the card there, confirms the payment and returns them to your `success_url` or `failure_url` with `?payment_id=...` appended. The URL carries a single-use token that expires with the payment's confirmation window, so hand it to the customer when you mint it rather than storing it.

A [wallet](#paypal-wallet) payment returns two URLs, and either can drive it:

```json
"next_action": {
  "type": "paypal_wallet",
  "redirect_url": "https://www.paypal.com/checkoutnow?token=...",
  "checkout_url": "https://app.swisspay.ai/checkout/pay/3ds_..."
}
```

* **`checkout_url`** -- the hosted checkout page, which renders the PayPal button for you. Send the customer there and Swisspay handles the approval, the capture and the return to your `success_url` or `failure_url`. Nothing to build.
* **`redirect_url`** -- the wallet approval URL, if you would rather redirect the customer yourself. When they return, [confirm the payment](#confirming-a-wallet-payment) -- with the hosted page, Swisspay confirms it for you.

A card payment the buyer has to resolve on an approval page -- which includes authentication when the connection's 3-D Secure setting is **Always**; see 3-D Secure -- returns:

```json
"next_action": {
  "type": "paypal_payer_action",
  "redirect_url": "https://www.paypal.com/..."
}
```

Send the customer to `redirect_url`. Nothing has been charged. When they return, [confirm the payment](#confirming-a-card-fields-payment) -- the same endpoint that finishes a card-fields or wallet payment, and the only thing that charges this one.

> Returning to your `success_url` does not mean the customer authenticated
>
> The buyer is sent back to `success_url` even when the step-up failed, carrying the outcome in query parameters (e.g. `?error=error&liability_shift=UNKNOWN`). Do not read the landing URL as success. `confirm` is where you find out: if the buyer never approved the payment it answers `422` with `the buyer has not approved this order yet`, and the payment stays `requires_action` so a second attempt at the approval URL can still complete it.

`next_action` can carry further fields used by advanced integrations that render the payment on their own page instead of using the hosted checkout -- ask your Swisspay contact if you need that. It is present only while the payment is still actionable. Once its window has passed (15 minutes from creation) the field is omitted, because the payment behind it can no longer be captured.

## PayPal card fields

Card details never reach your servers or ours: each input is an iframe owned by the card processing, so the card number, expiry and security code go straight there. That keeps your checkout page out of PCI scope, and it is the only supported way to take card payments in a browser.

It takes two calls instead of one.

**1. Start the payment with no card.** Send `payment_method.type: "card_fields"` and nothing else under `payment_method` -- sending card details alongside it returns `422`.

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "CHF",
    "reference": "order_42",
    "payment_method": { "type": "card_fields" }
  }'
```

The payment comes back `requires_action` with the `paypal_card_fields` next action above. Nothing has been charged.

**2. Send the buyer to the hosted page.** Send them to `next_action.checkout_url` and the hosted checkout page renders the card fields, runs any 3-D Secure challenge in place, confirms the payment and returns the buyer to your `success_url` / `failure_url`. If you set neither URL, the page shows the outcome itself. Nothing else to build.

An advanced integration that renders the card fields on your own checkout page -- where you confirm the payment yourself -- is available; ask your Swisspay contact. However the card is collected, confirming works the same way:

### Confirming a card-fields payment

```bash
curl -X POST https://app.swisspay.ai/api/v1/payments/pay_.../confirm \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

Returns the settled payment, exactly like `POST /api/v1/payments` would. A declined card comes back `status: "failed"` with the issuer's reason in `failure.code` -- for example `insufficient_funds`, `expired_card` or `invalid_cvv` -- not a generic decline.

Approval by PayPal is not payment. Nothing is charged until `confirm` succeeds, so a customer who closes the tab is never billed; the payment simply stays `requires_action`. The confirmation window expires 15 minutes after the payment is created.

Level 2/3 interchange data is sent automatically from the [line items](#line-items) and order amounts on the original request.

## PayPal wallet

The customer pays from their own PayPal balance or saved funding source instead of entering a card. Same two-call shape as card fields -- start the payment, let the customer approve, then confirm -- but they approve on PayPal rather than in a card form on your page.

**1. Start the payment.** Send `payment_method.type: "paypal"` with no card details, plus the `success_url` and `failure_url` PayPal returns the customer to. Both are required: without them an approved customer has nowhere to land.

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "CHF",
    "reference": "order_42",
    "payment_method": { "type": "paypal" },
    "success_url": "https://shop.example.com/orders/42/complete",
    "failure_url": "https://shop.example.com/orders/42/cancelled"
  }'
```

The payment comes back `requires_action` with the `paypal_wallet` next action above. Nothing has been charged.

**2. Send the customer to approve.** Send them to `next_action.checkout_url` and Swisspay's hosted page renders the PayPal button for you -- or redirect them to `next_action.redirect_url` yourself. PayPal shows the customer the amount and your business name, and returns them to your `success_url` on approval or `failure_url` on cancel.

If PayPal accepts the order but returns no approval link, the call fails with `502` and `code: "missing_approval_url"` rather than handing you a payment the customer could never approve. No money has moved -- retry with a new `Idempotency-Key`.

### Shipping on the PayPal approval page

Whether PayPal shows the customer a shipping step is decided per payment, from the order itself -- there is no setting to configure.

PayPal is sent a shipping address, shown read-only, when **both** are true:

* at least one entry in [`line_items`](#line-items) is a physical good -- that is, its `category` is `PHYSICAL_GOODS`, which is also the default when `category` is omitted; and
* the payment carries a `delivery_address` -- either the payment-level field, or the one stored on the attached customer.

Otherwise PayPal shows no shipping step at all. That covers a digital-goods or donation basket (every `category` is `DIGITAL_GOODS` or `DONATION`, so nothing needs shipping), and a physical basket sent without any `delivery_address`.

Because the address is read-only, the customer cannot redirect the order to an address you have not seen: what you send is what PayPal ships to. Swisspay never asks PayPal for the address on file, so a physical order with no `delivery_address` will not collect one on your behalf -- send the address with the payment if you need PayPal to show it.

### Confirming a wallet payment

```bash
curl -X POST https://app.swisspay.ai/api/v1/payments/pay_.../confirm \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: $(uuidgen)"
```

The same endpoint that confirms a card-fields payment. Returns the settled payment.

> **Approval is not payment.**
>
> The customer landing back on your `success_url` means they approved -- not that you have been paid. Do not fulfil on the return alone; fulfil when this confirm call succeeds. A customer who abandons the PayPal page is never billed: the payment simply stays `requires_action` until the confirmation window expires, 15 minutes after it was created.

Poll [`GET /api/v1/payments/{id}`](#retrieve-a-payment) if you need to know whether a payment was completed out of band.

Set `capture_method: "manual"` on the original request to [hold the funds](#manual-capture-authorize-then-capture) instead of taking them -- confirm then returns `authorized`, and you capture or void later.

Everything else about the order is identical to a card payment: the same [line items](#line-items), amounts, `reference` and `shopper_statement` are sent, and the same fees apply.

A settled wallet payment reports the customer's PayPal account as the instrument. There is no card in the transaction, so `brand` and `last4` are `null`. `payment_method_id` is `null` on a one-off wallet payment like the one below; ask for the account to be kept and it carries the saved instrument's id instead -- see [saving a PayPal wallet for later charges](#saving-a-paypal-wallet-for-later-charges):

```json
{
  "id": "pay_01HQXYZABCDEF0123456789",
  "object": "payment",
  "amount": 2999,
  "currency": "CHF",
  "status": "succeeded",
  "reference": "order_42",
  "customer": "cus_01hqxyzabcdef0123456789",
  "payment_method": {
    "type": "paypal",
    "brand": null,
    "last4": null
  },
  "payment_method_id": null,
  "failure": null,
  "created": 1747123200
}
```

### Saving a PayPal wallet for later charges

Send `save_payment_method: true` with a `customer` to keep the buyer's PayPal account on file. The instrument is saved when you [confirm](#confirming-a-wallet-payment) the payment, not when you create it, so the `pm_...` id arrives on the **confirm** response -- the initial response carries `payment_method_id: null` alongside `status: "requires_action"`.

```bash
curl https://app.swisspay.ai/api/v1/payments/pay_01HQXYZABCDEF0123456789/confirm \
  -X POST \
  -u sk_test_your_key:
```

```json
{
  "id": "pay_01HQXYZABCDEF0123456789",
  "object": "payment",
  "status": "succeeded",
  "customer": "cus_01hqxyzabcdef0123456789",
  "payment_method": {
    "type": "paypal",
    "brand": null,
    "last4": null
  },
  "payment_method_id": "pm_01HQXYZABCDEF0123456789",
  "failure": null,
  "created": 1747123200
}
```

Charge it later with `stored_payment_method` and `merchant_initiated: true`, exactly as you would a [saved card](#saved-cards) -- PayPal resolves the buyer from the saved instrument, so the charge needs no approval from them:

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -u sk_test_your_key: \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "CHF",
    "reference": "sub_2",
    "customer": "cus_01hqxyzabcdef0123456789",
    "stored_payment_method": "pm_01HQXYZABCDEF0123456789",
    "merchant_initiated": true,
    "recurring_processing_model": "subscription"
  }'
```

> **A returning buyer saving against a second customer gets a second `pm_...` id.** PayPal identifies a saved wallet by the buyer's PayPal account, so if the same person pays you again under a different `customer`, both customers end up with their own `pm_...` id for that one PayPal account. Each id is charged the same way and either can be [removed](/api-reference/payment-methods.md) independently; removing one leaves the other working. Always charge the `pm_...` id that came back on **that** customer's payment -- an id belonging to another customer returns `422` with `stored_payment_method not found`.

### Response -- success

`201 Created`

```json
{
  "id": "pay_01HQXYZABCDEF0123456789",
  "object": "payment",
  "amount": 2999,
  "currency": "USD",
  "status": "succeeded",
  "reference": "order_42",
  "shopper_statement": "ACME ORDER 42",
  "customer": "cus_01hqxyzabcdef0123456789",
  "payment_method": {
    "type": "card",
    "brand": "visa",
    "last4": "1111"
  },
  "payment_method_id": "pm_01HQXYZABCDEF0123456789",
  "renews_payment": null,
  "failure": null,
  "created": 1747123200
}
```

`customer` is `null` when no customer was attached to the payment.

`renews_payment` is the `pay_...` id the payment was created with as [`renews_payment`](#charge-a-subscription-rebill), and `null` on any payment that did not name one.

`payment_method_id` is the `pm_...` id of the saved card when the card was saved (by default, or via `save_payment_method: true`). It is `null` when no card was saved, and when the save was accepted but produced no saved card -- which never resolves. Treat `null` as "unconfirmed" and check [`GET /api/v1/customers/{id}/payment_methods`](/api-reference/payment-methods.md#list-a-customers-saved-cards); see Saved cards.

### Response -- decline

A decline is not an HTTP error. The API returns `200 OK` with `status: "failed"` and a populated `failure` object:

```json
{
  "id": "pay_01HQXYZABCDEF0123456789",
  "object": "payment",
  "amount": 2999,
  "currency": "USD",
  "status": "failed",
  "reference": "order_42",
  "shopper_statement": null,
  "customer": null,
  "payment_method": {
    "type": "card",
    "brand": "visa",
    "last4": "1111"
  },
  "failure": {
    "code": "do_not_honor",
    "message": "The card issuer declined this payment. Contact your card issuer, or try a different card."
  },
  "created": 1747123200
}
```

### Example -- successful charge

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_42_attempt_1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "order_42",
    "payment_method": {
      "type": "card",
      "number": "4111111111111111",
      "exp_month": 3,
      "exp_year": 2030,
      "cvc": "737",
      "holder_name": "Jane Doe"
    }
  }'
```

### Example -- payment with a custom statement descriptor

Set `shopper_statement` to control the text shown on the shopper's bank or card statement.

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_48" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "order_48",
    "shopper_statement": "ACME ORDER 48",
    "payment_method": { "type": "card", "number": "4111111111111111", "exp_month": 3, "exp_year": 2030, "cvc": "737" }
  }'
```

### Example -- payment with an existing customer

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_44" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "order_44",
    "customer": "cus_01hqxyzabcdef0123456789",
    "payment_method": { "type": "card", "number": "4111111111111111", "exp_month": 3, "exp_year": 2030, "cvc": "737" }
  }'
```

### Example -- payment with inline customer creation

Passing a customer object instead of an id creates the customer in the same request. The returned `Payment` has `customer` set to the new customer's id.

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_45" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "order_45",
    "customer": { "email": "jane@example.com", "name": "Jane Doe", "external_id": "user_42" },
    "payment_method": { "type": "card", "number": "4111111111111111", "exp_month": 3, "exp_year": 2030, "cvc": "737" }
  }'
```

### Example -- payment with shopper context (recommended for lower fraud scores)

Pass the shopper's IP and device data when you're calling Swisspay from your server on the shopper's behalf. The risk screening uses them for geo, velocity, and device-mismatch checks.

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_46" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "order_46",
    "shopper_ip": "203.0.113.42",
    "device_fingerprint": "dd-8a2c91be-3f7e-42d1-9c4a-example",
    "browser_info": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15",
      "language": "en-US",
      "screen_height": 1080,
      "screen_width": 1920,
      "time_zone_offset": -120
    },
    "payment_method": { "type": "card", "number": "4111111111111111", "exp_month": 3, "exp_year": 2030, "cvc": "737" }
  }'
```

### Example -- declined charge

In the sandbox, simulate a decline by setting `payment_method.holder_name` to a [rejection trigger](#test-cards) -- here `CCREJECT-REFUSED`, which the issuer answers with "do not honor":

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_43_attempt_1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "order_43",
    "payment_method": {
      "type": "card",
      "number": "4111111111111111",
      "exp_month": 3,
      "exp_year": 2030,
      "cvc": "737",
      "holder_name": "CCREJECT-REFUSED"
    }
  }'
```

### Example -- idempotent replay

Reusing the same `Idempotency-Key` with the same body returns the original response without re-charging the card:

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_42_attempt_1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "order_42",
    "payment_method": {
      "type": "card",
      "number": "4111111111111111",
      "exp_month": 3,
      "exp_year": 2030,
      "cvc": "737",
      "holder_name": "Jane Doe"
    }
  }'
```

## Saving a card

Saving is **on by default**: any payment with a `customer` attached tokenizes the card and returns its `pm_...` id in `payment_method_id`. You can set `save_payment_method: false` to opt a payment out, or `save_payment_method: true` to force it (shown below). The default is configurable per account via the `store_payment_method_default` setting. Because saving is on by default, you are responsible for collecting cardholder consent in your checkout -- see Saved cards -> Consent.

When no `customer` is attached, saving is simply skipped -- the payment still succeeds and no card is stored. Only an *explicit* `save_payment_method: true` with no customer is an error. Omitting the customer on an explicit `true` returns:

```json
{
  "error": {
    "type": "validation_error",
    "code": "invalid_params",
    "message": "save_payment_method requires a customer"
  }
}
```

Where an instrument cannot be stored, an explicit `save_payment_method: true` is rejected rather than silently ignored, because answering `201` with `payment_method_id: null` would leave you believing a card is on file:

```json
{
  "error": {
    "type": "validation_error",
    "code": "invalid_params",
    "message": "save_payment_method is not supported by the <provider> provider"
  }
}
```

Only the explicit `true` is refused; omitting the field never errors. One timing difference worth knowing: on a wallet or `card_fields` payment the card is saved when you confirm the payment rather than when you create it, so the saved id appears on the confirm response. See [Notes on card payments](#notes-on-card-payments) for the rest.

A `null` `payment_method_id` on a successful payment that asked to save can mean the save produced no saved card at all, which never resolves -- confirm against the customer's saved cards and re-collect if nothing appears. See Saved cards -- token lifecycle.

### Example -- save a card at checkout

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_50_attempt_1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "order_50",
    "customer": "cus_01hqxyzabcdef0123456789",
    "save_payment_method": true,
    "payment_method": {
      "type": "card",
      "number": "4111111111111111",
      "exp_month": 3,
      "exp_year": 2030,
      "cvc": "737",
      "holder_name": "Jane Doe"
    }
  }'
```

The response includes `payment_method_id: "pm_..."` when the card was saved -- the normal case. Treat a `null` as unconfirmed and check the customer's saved cards.

## Charging a saved card

Send `stored_payment_method` with the `pm_...` id instead of a raw `payment_method` object. Exactly one of the two must be present -- sending both, or neither, returns `422`. The saved card must belong to the `customer` on the request.

Use `merchant_initiated: true` for off-session charges (e.g. subscription rebills) where no shopper is present and no 3DS challenge is possible. For on-session charges (shopper present at checkout), omit the field or pass `false`.

### Example -- on-session charge with a saved card

The shopper is present at checkout; 3DS may trigger.

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: order_51_attempt_1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "order_51",
    "customer": "cus_01hqxyzabcdef0123456789",
    "stored_payment_method": "pm_01HQXYZABCDEF0123456789"
  }'
```

### Example -- off-session merchant-initiated charge

No shopper present; no 3DS challenge. Use for subscription rebills, deferred charges, or scheduled payments.

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: sub_jan_2026_cus_01hq" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "sub_jan_2026",
    "customer": "cus_01hqxyzabcdef0123456789",
    "stored_payment_method": "pm_01HQXYZABCDEF0123456789",
    "merchant_initiated": true
  }'
```

### Set up a subscription

The first (shopper-present) payment tokenizes the card and records the subscription model:

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: sub_setup_cus_01hq" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "sub_setup",
    "customer": "cus_01hqxyzabcdef0123456789",
    "save_payment_method": true,
    "recurring_processing_model": "subscription",
    "payment_method": {
      "type": "card",
      "number": "4111111111111111",
      "exp_month": 3,
      "exp_year": 2030,
      "cvc": "737",
      "holder_name": "Jane Doe"
    }
  }'
```

### Charge a subscription rebill

Later, off-session charges reference the saved card. The `subscription` model set at setup is inherited automatically from the saved card, so it is not resent on the rebill -- you only need `merchant_initiated`:

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: sub_2026_02_cus_01hq" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "sub_2026_02",
    "customer": "cus_01hqxyzabcdef0123456789",
    "stored_payment_method": "pm_01HQXYZABCDEF0123456789",
    "merchant_initiated": true,
    "renews_payment": "pay_01HQXYZABCDEF0123456789"
  }'
```

`renews_payment` names the payment this charge renews. Pass the subscription's first payment, or the previous renewal -- either identifies the same subscription. It is optional as far as the charge is concerned, and the card network classification comes from `recurring_processing_model`, not from this field. What it changes is what Swisspay knows about the charge: if you sell through a connected Shopify store and the first payment was a store checkout, a charge that names it becomes a new Shopify renewal order. A charge to the same customer that does not name it is treated as unrelated, and never reaches your store.

### Charge the customer's last saved card

```bash
curl https://app.swisspay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: sub_2026_03_cus_01hq" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2999,
    "currency": "USD",
    "reference": "sub_2026_03",
    "customer": "cus_01hqxyzabcdef0123456789",
    "use_last_payment_method": true,
    "merchant_initiated": true,
    "recurring_processing_model": "subscription"
  }'
```

## Manual capture (authorize, then capture)

By default a payment is charged the moment you create it. Sending `capture_method: "manual"` instead places a **hold** on the card: the funds are reserved but not taken, and you decide later whether to collect them or let them go.

This is what you want when you cannot promise the goods at checkout -- stock you have to confirm, an address you have to verify, an order someone has to approve.

The lifecycle is:

1. `POST /api/v1/payments` with `capture_method: "manual"` -> the payment comes back **`authorized`**.
2. `POST /api/v1/payments/:id/capture` -> takes the money, payment becomes **`succeeded`**.
3. `POST /api/v1/payments/:id/void` -> releases the hold, payment becomes **`voided`**.

Both steps are terminal in opposite directions: once captured you can [refund](/api-reference/refunds.md), and once voided the payment is closed for good and cannot be captured.

> **Holds expire, and there are two deadlines -- not one.** PayPal guarantees to honor an authorization for **3 days**. It permits a capture attempt for up to **29 days**, but past day 3 the issuer is no longer obliged to stand behind the hold, so a late capture can be refused even though the authorization is still open. Both deadlines are on the payment: `authorization_guaranteed_until` is the **3-day** point until which the hold is guaranteed, and `authorization_expires` is the **29-day** point after which capture is impossible. Capture before the guarantee lapses wherever you can. Availability is currently PayPal-only.

The gap between the two is where late captures live. A capture attempted after `authorization_guaranteed_until` but before `authorization_expires` is accepted by PayPal and usually succeeds -- the issuer is simply no longer obliged to honor it, so it can come back declined for a card that has not changed and has the funds. Treat that window as best-effort: keep the retry, but do not promise the buyer their order until the capture returns `succeeded`.

`authorization_guaranteed_until` is derived from PayPal's stated honour period rather than reported per-authorization, and it is omitted when SwissPay cannot establish it. A missing field means the guarantee is unknown -- never that the hold carries none.

### Capture a payment

```
POST /api/v1/payments/:id/capture
```

Captures the full authorized amount. Takes no request body -- partial and repeated captures are not supported, so a capture always collects the whole amount and closes the authorization.

| Header            | Required | Notes                                                                                                                 |
| ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `Authorization`   | yes      | `Bearer sk_test_...`                                                                                                  |
| `Idempotency-Key` | no       | Optional here, unlike on create. Send one and a retry replays the first response instead of risking a second capture. |

```bash
curl -X POST https://app.swisspay.ai/api/v1/payments/pay_01HQXYZABCDEF0123456789/capture \
  -H "Authorization: Bearer sk_test_example" \
  -H "Idempotency-Key: capture-order-42"
```

```json
{
  "id": "pay_01HQXYZABCDEF0123456789",
  "object": "payment",
  "amount": 2999,
  "currency": "CHF",
  "status": "succeeded",
  "capture_method": "manual",
  "reference": "order_42",
  "payment_method": { "type": "card", "brand": "visa", "last4": "1111" },
  "created": 1718600000
}
```

### Void a payment

```
POST /api/v1/payments/:id/void
```

Releases the hold without charging. No money ever moved, so this is **not** a refund and no `Refund` object is created.

```bash
curl -X POST https://app.swisspay.ai/api/v1/payments/pay_01HQXYZABCDEF0123456789/void \
  -H "Authorization: Bearer sk_test_example"
```

```json
{
  "id": "pay_01HQXYZABCDEF0123456789",
  "object": "payment",
  "amount": 2999,
  "currency": "CHF",
  "status": "voided",
  "capture_method": "manual",
  "reference": "order_42",
  "payment_method": { "type": "card", "brand": "visa", "last4": "1111" },
  "created": 1718600000
}
```

Voiding an already-expired hold succeeds: PayPal has released it anyway, so the payment is simply closed as `voided`.

### Errors

| Condition                                                                               | Status | Code                     |
| --------------------------------------------------------------------------------------- | ------ | ------------------------ |
| Payment is not `authorized` (already captured, voided, or an automatic-capture payment) | `422`  | `invalid_params`         |
| The authorization expired before you captured it                                        | `422`  | `invalid_params`         |
| No payment with that id on your account                                                 | `404`  | `not_found`              |
| Your account is not cleared for live processing                                         | `422`  | `processing_not_enabled` |
| Your account was cleared but is no longer active                                        | `422`  | `processing_not_active`  |

The last two are the same [live-processing refusals](#notes-on-card-payments) that apply to creating and confirming a payment, and they are checked before the conditions above -- a suspended account is told that rather than sent off investigating the authorization. The hold is untouched: the payment stays `authorized`, so you can [void](#void-a-payment) it, and it becomes capturable again on its own if the account is restored while the authorization is still open. Voids are never refused on these grounds.

### With PayPal card fields

Manual capture composes with [card fields](#paypal-card-fields) and the [wallet](#paypal-wallet) alike. Create the payment with `capture_method: "manual"` alongside `payment_method.type: "card_fields"` or `"paypal"`; when the buyer submits the card or approves on PayPal, [confirm](#confirming-a-card-fields-payment) places the hold and returns `authorized` rather than `succeeded`. Capture or void it from there as above.

## Retrieve a payment

```
GET /api/v1/payments/:id
```

Returns the `Payment` with the given `id`. The shape is identical to the create response.

```bash
curl https://app.swisspay.ai/api/v1/payments/pay_01HQXYZABCDEF0123456789 \
  -H "Authorization: Bearer sk_test_example"
```

Returns `404` with `type: "invalid_request_error"` if the payment does not exist on your account.

## List payments

```
GET /api/v1/payments
```

Returns your payments, most recent first.

### Query parameters

| Parameter  | Type    | Default | Notes                             |
| ---------- | ------- | ------- | --------------------------------- |
| `page`     | integer | `1`     | Page number, 1-indexed.           |
| `per_page` | integer | `20`    | Page size, between `1` and `100`. |

### Response

```json
{
  "data": [{ "id": "pay_...", "object": "payment", "amount": 2999, "...": "..." }],
  "has_more": true,
  "total_count": 137
}
```

`has_more` is `true` when more pages exist beyond the current one. Cursor pagination is on the roadmap; for now, page through with `page`.

```bash
curl "https://app.swisspay.ai/api/v1/payments?page=1&per_page=20" \
  -H "Authorization: Bearer sk_test_example"
```

## The Payment object

| Field                            | Type           | Description                                                                                                                                                                                                                                                                                                             |
| -------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                             | string         | Unique identifier, prefixed `pay_`.                                                                                                                                                                                                                                                                                     |
| `object`                         | string         | Always `"payment"`.                                                                                                                                                                                                                                                                                                     |
| `amount`                         | integer        | Charge amount in minor units.                                                                                                                                                                                                                                                                                           |
| `currency`                       | string         | ISO 4217 currency code.                                                                                                                                                                                                                                                                                                 |
| `status`                         | string         | One of `succeeded`, `failed`, `pending`, `requires_action`, `authorized` (funds held, awaiting [capture or void](#manual-capture-authorize-then-capture)), `voided` (hold released, never charged).                                                                                                                     |
| `capture_method`                 | string         | `"automatic"` or `"manual"` -- see [Manual capture](#manual-capture-authorize-then-capture).                                                                                                                                                                                                                            |
| `authorization_expires`          | integer        | Present only while `status` is `authorized`. Unix seconds after which the hold lapses and the payment can no longer be captured. The outer of the two deadlines -- see `authorization_guaranteed_until` for the point until which capture is guaranteed.                                                                |
| `authorization_guaranteed_until` | integer        | Present only while `status` is `authorized`, on PayPal, and when SwissPay can establish it. Unix seconds until which the provider guarantees to honor the hold. Capturing after this and before `authorization_expires` is permitted but best-effort -- the issuer may refuse. Omitted means unknown, not unguaranteed. |
| `reference`                      | string \| null | The `reference` you supplied at create time, if any.                                                                                                                                                                                                                                                                    |
| `customer`                       | string \| null | The `cus_...` id of the [customer](/api-reference/customers.md) attached to this payment, or `null`.                                                                                                                                                                                                                    |
| `payment_method`                 | object         | Sanitized card details.                                                                                                                                                                                                                                                                                                 |
| `payment_method.type`            | string         | `"card"`, or `"paypal"` when the customer paid from their [PayPal account](#paypal-wallet) -- in which case `brand` and `last4` are `null`, as no card was involved.                                                                                                                                                    |
| `payment_method.brand`           | string         | Card brand (e.g. `visa`, `mc`, `amex`).                                                                                                                                                                                                                                                                                 |
| `payment_method.last4`           | string         | Last four digits of the PAN.                                                                                                                                                                                                                                                                                            |
| `payment_method_id`              | string \| null | The `pm_...` id of the saved card when the card was saved (by default, or via `save_payment_method: true`). `null` if no card was saved, or if the save was accepted and produced no saved card -- which never resolves, so confirm against the customer's saved cards (see Saved cards).                               |
| `amount_refunded`                | integer        | How much of this payment has been refunded, in minor units. `0` when nothing has been.                                                                                                                                                                                                                                  |
| `refunded`                       | boolean        | `true` once the full amount has been refunded.                                                                                                                                                                                                                                                                          |
| `partially_refunded`             | boolean        | `true` while some -- but not all -- of the amount has been refunded.                                                                                                                                                                                                                                                    |
| `failure`                        | object \| null | `null` unless `status` is `failed`. Contains `code` (machine-readable) and `message` (human-readable).                                                                                                                                                                                                                  |
| `created`                        | integer        | Creation time as Unix seconds.                                                                                                                                                                                                                                                                                          |

## Idempotency

Every `POST /api/v1/payments` request must include an `Idempotency-Key` header. The value is an opaque string you choose -- a UUID or your own order-attempt id works well.

How replays behave:

* **Same key, same body** -- the original response is returned verbatim. The provider is not called again.
* **Same key, different body** -- `409 idempotency_error`. Pick a fresh key for a different payment.
* **Different keys** -- treated as independent requests.

Keys are scoped to the API key that created them. If you rotate API keys, the new key starts with a clean idempotency namespace.

Always retry network failures with the same `Idempotency-Key`. If Swisspay or the upstream provider couldn't determine whether the charge went through, the replay is the safe way to find out.

## Test cards

A sandbox workspace never charges a real card, and accepts **test cards only**. Any of these numbers, with any future expiry and any CVC (3 digits; 4 for American Express), authorises successfully:

| Brand            | Number             |
| ---------------- | ------------------ |
| Visa             | `4111111111111111` |
| Mastercard       | `2223000048400011` |
| American Express | `371449635398431`  |

To simulate a decline, keep any test card number and set `payment_method.holder_name` to a **rejection trigger**. The payment returns `200` with `status: "failed"` and the matching `failure.code`:

| `holder_name` trigger | `failure.code`       |
| --------------------- | -------------------- |
| `CCREJECT-REFUSED`    | `do_not_honor`       |
| `CCREJECT-IF`         | `insufficient_funds` |
| `CCREJECT-EC`         | `expired_card`       |
| `CCREJECT-CVV_F`      | `invalid_cvv`        |
| `CCREJECT-IA`         | `invalid_card`       |
| `CCREJECT-IRC`        | `restricted_card`    |
| `CCREJECT-BANK_ERROR` | `declined`           |

See [Example -- declined charge](#example--declined-charge) for a full request. `failure.message` alongside the code is written for the cardholder, so it is safe to show them.

## Errors

Errors use a single envelope:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "missing_field",
    "message": "amount is required"
  }
}
```

* `type` is broad -- one of the categories below.
* `code` is a machine-readable code you can branch on.
* `message` is a human-readable description for logs and developer tooling.

| HTTP | `type`                  | When                                                                                                                                                                                       |
| ---- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400  | `invalid_request_error` | Malformed JSON, missing required field, unknown currency, missing `Idempotency-Key`.                                                                                                       |
| 401  | `authentication_error`  | Missing or invalid `Authorization` header, or the key's mode no longer matches the workspace.                                                                                              |
| 404  | `invalid_request_error` | The payment does not exist on your account.                                                                                                                                                |
| 409  | `idempotency_error`     | The `Idempotency-Key` was reused with a different request body.                                                                                                                            |
| 422  | `validation_error`      | The request was well-formed but a field is unacceptable (e.g. `amount <= 0`, expired card).                                                                                                |
| 502  | `provider_error`        | The upstream provider returned an error, refused the connection, or answered in a way the payment cannot proceed from -- see `missing_approval_url` under [PayPal wallet](#paypal-wallet). |
| 503  | `provider_error`        | The upstream provider did not respond in time. Safe to retry with the same `Idempotency-Key`.                                                                                              |

Card declines are not errors. They surface as `200 OK` with `status: "failed"` and a populated `failure` object on the `Payment`.
