> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tagada.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Server-to-server payments

> The complete S2S card payment lifecycle: tokenize, instrument, 3DS, charge, capture, refund.

# Server-to-server payments

Once you have a Processing Key (`tp_sk_…`) + a `storeId`, the entire payment lifecycle is yours. No checkout session, no funnel, no UI assumptions on our side.

***

## The four steps

```
1. Tokenize  (browser)  → tagadaToken
2. Instrument (server)  → paymentInstrumentId  ─┐
                                                ├─ optional 3DS
3. 3DS        (server)  → threedsSessionId    ─┘
4. Charge     (server)  → paymentId
```

Steps 2 and 3 happen on your server. Step 1 happens in your customer's browser. Step 4 closes the loop.

***

## Step 1 — Tokenize the card (browser)

Use `@tagadapay/core-js`. The card data goes directly to BasisTheory’s vault — never to your server, never to ours:

```ts theme={null}
import { Tokenizer } from '@tagadapay/core-js';

const tokenizer = new Tokenizer({ environment: 'production' });

const tagadaToken = await tokenizer.tokenizeCard({
  cardNumber: '4242 4242 4242 4242',
  expiryDate: '12/30',
  cvc: '123',
  cardholderName: 'Jane Doe',
});

// → 'eyJraWQiOiJ...' — a single-use, opaque token
// Send it to your server. It expires in ~30 minutes.
```

<Tip>
  **PCI scope-out.** Because the PAN never traverses your stack, your PCI obligations stay at SAQ-A. See [`@tagadapay/core-js`](/developer-tools/payments/headless-payments) for the full tokenization API.
</Tip>

***

## Step 2 — Create a payment instrument (server)

Convert the single-use `tagadaToken` into a reusable `paymentInstrument`:

```ts theme={null}
const charging = new Tagada(process.env.MERCHANT_42_PROCESSING_KEY!);

const { paymentInstrument, customer } = await charging.paymentInstruments.createFromToken({
  tagadaToken,
  storeId: 'store_xxx',
  customerData: {
    email: 'jane@example.com',
    firstName: 'Jane',
    lastName: 'Doe',
    phone: '+33612345678',
    billingAddress: {
      address1: '10 rue de Rivoli',
      city: 'Paris',
      postal: '75001',
      country: 'FR',   // ISO 3166-1 alpha-2 — required; acquirers reject an empty country
    },
  },
});

// {
//   paymentInstrument: { id: 'pi_xxx', last4: '4242', brand: 'visa', ... },
//   customer:          { id: 'cust_xxx', ... }
// }
```

The same `customer` is reused if you provide a matching email — one customer can hold multiple instruments.

<Note>
  **Address field names.** The platform stores and validates addresses as `address1`, `address2`, `postal`, `city`, `state`, `country` (ISO 3166-1 alpha-2), plus `firstName` / `lastName`. The SDK still accepts the legacy `line1` / `line2` / `postalCode` aliases and maps them for you, but prefer the canonical names.
</Note>

<Note>
  **Addresses are optional — including for digital products.** Neither `billingAddress` nor `shippingAddress` is required to charge: selling digital goods with no shipping address works out of the box. If you do provide an address, it is only forwarded to the acquirer when it carries a valid ISO 3166-1 alpha-2 `country` — an incomplete address (e.g. missing `country`) is safely omitted from the acquirer call instead of failing the payment. Providing a complete `billingAddress` is still **recommended**: it improves issuer approval rates (AVS) and 3DS frictionless outcomes.
</Note>

***

## Step 3 — 3DS (usually nothing to do)

<Note>
  **Most integrations skip this step.** Whether 3-D Secure runs, and *how*, is decided by the PSP/acquirer behind your TPA — and most of them handle it themselves. There are two ways it can happen:
</Note>

### Option 1 — PSP/acquirer-hosted 3DS (the default)

Modern PSPs (Adyen, Stripe, …) own the 3DS challenge. You **don't** call `threeds.createSession`. You just charge (Step 4) with a `returnUrl`; if the issuer requires SCA, `payments.process` comes back with `requireAction: 'redirect'` and a `redirectUrl` pointing at the **processor's own hosted 3DS page**. You send the customer there, they authenticate, and they return to your `returnUrl`. Nothing to collect, nothing to pass through — the processor reconciles the authentication internally.

This is the path the rest of this guide uses. **If your TPA is on a hosted-3DS PSP, ignore the code below and go straight to Step 4.**

### Option 2 — Tagada standalone 3DS (the exception)

Some gateway-style processors (e.g. **NMI**) don't host 3DS, but they *do* accept pre-authenticated 3DS values (CAVV, ECI, DS transaction id…) on the charge. For those — and only when the acquirer is compatible and takes those values as input — Tagada can run the 3DS flow for you, collect the values, and pass them raw to the acquirer. You opt in explicitly:

```ts theme={null}
// Only for processors that need externally-supplied 3DS values (e.g. NMI).
// Hosted-3DS PSPs like Adyen / Stripe do NOT use this — see Option 1.
const threeds = await charging.threeds.createSession({
  provider: 'basis_theory',
  storeId: 'store_xxx',
  paymentInstrumentId: paymentInstrument.id,
  sessionData: {
    /* device info collected client-side via core-js */
  },
});

// status === 'challenge_required' → render threeds.challengeUrl in an iframe;
//                                   the customer authenticates in-browser.
// status === 'frictionless'      → no challenge; proceed straight to charge.
//
// Pass threeds.id to payments.process() as `threedsSessionId` (Step 4) so the
// authenticated values flow through to the acquirer.
```

<Note>
  **When does 3DS trigger?** When the card flow has `threeDsEnabled: true` (set per-merchant via Payment Flows) or the issuer requests SCA. **How** it runs is up to the processor: hosted PSPs return `requireAction: 'redirect'` (Option 1); gateways that need raw values use the standalone session (Option 2). When in doubt, skip this step and just handle the `requireAction` that the charge returns.
</Note>

***

## Step 4 — Charge

```ts theme={null}
const { payment } = await charging.payments.process({
  paymentInstrumentId: paymentInstrument.id,
  storeId: 'store_xxx',
  amount: 4999,           // minor units — 4999 = €49.99
  currency: 'EUR',
  paymentMethod: 'card',
  mode: 'purchase',       // 'auth' to authorize-only
  threedsSessionId: threeds?.id,  // if you ran 3DS upfront
  // OPTIONAL — recommended for approval rates, but a charge works without any
  // address (digital products). If set, include a 2-letter `country`.
  billingAddress: {
    address1: '10 rue de Rivoli',
    city: 'Paris',
    postal: '75001',
    country: 'FR',
  },
  // OPTIONAL — only for physical goods; omit entirely for digital products.
  // shippingAddress: { address1: '...', city: '...', postal: '...', country: 'FR' },
  // Where the issuer's hosted 3DS / SCA page sends the customer back. REQUIRED
  // for any flow that can challenge (e.g. Adyen on EU cards) — without it the
  // payment can't be resumed after authentication.
  returnUrl: 'https://your-site.example/checkout/return?order=order_42',
});

// {
//   id: 'pay_xxx',
//   status: 'captured' | 'authorized' | 'pending' | 'failed' | 'requires_action',
//   amount: 4999,
//   currency: 'EUR',
//   processor: 'proc_stripe_eu',
//   requireAction?: 'threeds' | 'redirect' | undefined,
//   requireActionData?: { ... },
//   ...
// }
```

### Handling the response

```ts theme={null}
// Always check `requireAction` first: a redirect/3DS step can surface with
// status 'pending' OR 'requires_action' depending on the processor.
if (payment.requireAction === 'redirect') {
  // Hosted 3-D Secure (e.g. Adyen on EU cards) OR a redirect APM (Klarna,
  // iDEAL, …). Send the customer to the issuer/processor page; they return
  // to your `returnUrl`, where you poll the payment until it's terminal.
  const redirect = payment.requireActionData.metadata.redirect; // { redirectUrl, method, data }
  if ((redirect.method ?? 'GET').toUpperCase() === 'POST') {
    // 3DS1: auto-submit a form POSTing redirect.data (MD + PaReq) to redirectUrl
  } else {
    window.location = redirect.redirectUrl;
  }
  return;
}
if (payment.requireAction === 'threeds') {
  // SDK-driven (non-redirect) 3DS: render the challenge from
  // payment.requireActionData with core-js, then resume the payment.
  return;
}

switch (payment.status) {
  case 'captured':
    // Money is moving. You're done.
    break;

  case 'authorized':
    // For mode: 'auth' — capture later with payments.capture(payment.id)
    break;

  case 'failed':
    // payment.failureReason is structured: 'do_not_honor', 'insufficient_funds', etc.
    break;
}
```

***

## Multi-TPA routing (cascade & weighted)

`payments.process` doesn't hit a processor directly — it runs the **payment
flow** attached to the `storeId`. A single merchant can own several TPAs (e.g. an
Adyen sub-merchant *and* a Stripe Merchant-of-Record account) and route across
them with **no code change** on your side: same `process` call, different
`storeId`.

* **Cascade (failover)** — the flow has a primary processor and an ordered
  fallback list (`fallbackMode: true`). If the primary declines (non-terminal),
  Tagada automatically retries the *same charge* on the next TPA, with a fresh
  payment method per attempt. Your code just sees the final `captured`.
* **Weighted** — multiple processors with weights; each charge is routed to
  one TPA by weight.

```ts theme={null}
const { payment } = await tagada.payments.process({
  paymentInstrumentId,
  storeId,            // ← resolves to a cascade or weighted flow
  amount: 4999,
  currency: 'EUR',
  returnUrl,
});

// Which acquirer ended up capturing, and every attempt along the way:
payment.processorName;                       // final capturing processor
payment.transactions?.map((t) => t.status);  // e.g. ['declined', 'captured']
```

<Note>
  A processing key restricted to one TPA resolves to that TPA's **merchant
  account**, so it can charge any store of that merchant — including a store
  whose flow spans several of the merchant's TPAs. You don't need a separate key
  per processor.
</Note>

You create and manage these flows yourself, over the API — see
[Payment flows (cascade & weighted)](/developer-tools/partners/payment-flows).

A runnable demo (picker for single / cascade / weighted, plus a "force the
primary to decline" toggle) lives in
[`partners-examples/02-merchant-checkout`](https://github.com/TagadaPay/partners-examples/tree/main/02-merchant-checkout).

***

## Auth + capture (split-flow)

Authorize now, capture later (hotels, car rentals, pre-orders, marketplaces):

```ts theme={null}
// Step A — authorize
const { payment: auth } = await charging.payments.process({
  paymentInstrumentId: 'pi_xxx',
  storeId: 'store_xxx',
  amount: 4999,
  currency: 'EUR',
  paymentMethod: 'card',
  mode: 'auth',
});

// auth.status === 'authorized'
// Funds reserved on the card, not yet moved.

// Step B — capture (within ~7 days for most networks)
const { payment: captured } = await charging.payments.capture(auth.id, {
  amount: 4999,  // can capture less than authorized; partial captures supported
});

// captured.status === 'captured'
```

You can also `payments.cancel(auth.id)` to release the authorization without capturing.

***

## Refunds

Full refund:

```ts theme={null}
await charging.payments.refund('pay_xxx');
```

Partial refund:

```ts theme={null}
await charging.payments.refund('pay_xxx', { amount: 2000 });  // €20 back
```

Refunds route through the same processor as the original charge — no decisions on your side.

***

## Merchant-initiated transactions (MIT)

For subscription renewals, retry logic, or any charge initiated without the customer present:

```ts theme={null}
// First charge (customer-initiated, with 3DS if required)
const { payment: first } = await charging.payments.process({
  paymentInstrumentId: 'pi_xxx',
  storeId: 'store_xxx',
  amount: 4999,
  currency: 'EUR',
  paymentMethod: 'card',
  mode: 'purchase',
  initiator: 'customer',  // default
  saveForFuturePayments: true,  // tells the processor we'll re-use
});

// Subsequent renewals (no customer present, no 3DS)
const { payment: renewal } = await charging.payments.process({
  paymentInstrumentId: 'pi_xxx',
  storeId: 'store_xxx',
  amount: 4999,
  currency: 'EUR',
  paymentMethod: 'card',
  mode: 'purchase',
  initiator: 'merchant',  // ← MIT flag
  reasonCode: 'recurring',
  // The original network transaction id from `first` is auto-included
  // by the SDK so processors honor the MIT exemption.
});
```

The SDK reuses the original network transaction id transparently; you don’t have to track it yourself.

***

## Listing and retrieving payments

```ts theme={null}
// Single payment
const payment = await charging.payments.retrieve('pay_xxx');

// List with filters
const list = await charging.payments.list({
  filters: {
    status: ['captured', 'authorized'],
    createdAt: { gte: '2026-04-01', lte: '2026-04-30' },
  },
  pagination: { page: 1, pageSize: 50 },
});
```

A Processing Key sees only payments belonging to its TPA. Partner keys can list across all TPAs they own.

***

## Webhooks

To learn about asynchronous events (3DS callbacks, settlement, disputes), set up webhooks at the TPA level:

```ts theme={null}
await charging.webhooks.endpoints.create({
  url: 'https://your-platform.com/webhooks/tagada',
  enabledEvents: [
    'payment.succeeded',
    'payment.failed',
    'payment.refunded',
    'dispute.opened',
    'dispute.closed',
  ],
  secret: 'whsec_xxx',  // or auto-generate
});
```

See [Webhooks & events](/developer-tools/node-sdk/webhooks-events) for the full event reference and signature verification.

***

## Errors you should handle

| HTTP | Code                    | Meaning                   | Action                                                   |
| ---- | ----------------------- | ------------------------- | -------------------------------------------------------- |
| 400  | `invalid_request`       | Bad params                | Fix the request, don’t retry                             |
| 401  | `auth_failed`           | Bad / revoked key         | Don’t retry; check key                                   |
| 402  | `card_declined`         | Issuer declined           | Show a friendly message; don’t retry the same instrument |
| 402  | `insufficient_funds`    | Issuer declined for funds | Same as above                                            |
| 409  | `duplicate_charge`      | Idempotency key collision | Re-fetch the existing payment                            |
| 422  | `processor_unavailable` | Processor down            | Retry with exponential backoff                           |
| 422  | `requires_action`       | 3DS / redirect needed     | Surface to customer, follow `requireAction`              |
| 429  | `rate_limited`          | Throttled                 | Honor `Retry-After` header                               |
| 5xx  | `server_error`          | Our problem               | Retry with exponential backoff                           |

The SDK throws typed errors:

```ts theme={null}
import { TagadaError } from '@tagadapay/node-sdk';

try {
  await charging.payments.process({ ... });
} catch (err) {
  if (err instanceof TagadaError) {
    if (err.code === 'card_declined') { ... }
    if (err.code === 'requires_action') { /* err.payment.requireAction === '...' */ }
  }
  throw err;
}
```
