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

# End-to-end example

> A complete partner integration in one file: signup → tokenize → charge → refund. Copy-paste ready.

# End-to-end example

A minimal but complete partner integration. Three files: a signup script, a server route, and a browser page. Together they exercise the entire S2S flow.

<Note>
  **Payfac partners.** This example uses `tpas.create()`. CRM-only partners should create the merchant, then submit [applications](/developer-tools/node-sdk/processing-applications) (or [embed](/developer-tools/partners/embed-onboarding)) — see [Accounts](/developer-tools/partners/accounts).
</Note>

<Note>
  **Want the full working repo?** The same flow as a runnable boilerplate — Node CLI for sub-merchant provisioning + Express checkout with card / Apple Pay / Klarna — lives at [github.com/TagadaPay/partners-examples](https://github.com/TagadaPay/partners-examples). Clone, fill in `.env`, and ship it.
</Note>

***

## File 1 — Signup script (run once per merchant)

```ts theme={null}
// scripts/onboard-merchant.ts
import Tagada from '@tagadapay/node-sdk';

const partner = new Tagada(process.env.TAGADA_PARTNER_KEY!);

async function onboardMerchant(input: {
  externalRef: string;
  legalName: string;
  country: string;
  currency: string;
  email: string;
}) {
  // 1. Create the TPA + owning merchant (idempotent on externalRef)
  const tpa = await partner.partners.processing.tpas.create({
    legalName: input.legalName, // required — the KYB legal entity
    country: input.country,
    currency: input.currency,
    externalRef: input.externalRef,
    metadata: { onboardedBy: 'partner-script' },
    // accountId: 'acc_xxx', // pass to attach to an existing merchant
  });

  // 2. Discover the auto-created store via the CRM API
  const stores = await partner.stores.list({ accountId: tpa.accountId });
  const storeId = stores.data[0].id;

  console.log('TPA:', tpa.id, '· merchant:', tpa.accountId, '· store:', storeId);

  // 3. Mint keys only if we don't already hold them (create is idempotent,
  //    but the secret/token is returned ONLY on first mint).
  const existing = await yourDb.merchants.findUnique({ where: { id: input.externalRef } });

  let processingKey: string | undefined = existing?.tagadaProcessingKey;
  let crmKey: string | undefined = existing?.tagadaCrmKey;

  if (!processingKey) {
    // Processing Key (tp_sk_…) — charging & reporting for this TPA
    const key = await partner.partners.processing.tpas.keys.create(tpa.id);
    processingKey = encrypt(key.secret);
    console.log('Processing key:', key.label, key.prefix, '… (secret stored)');
  }

  if (!crmKey) {
    // CRM Key (sk_crm_…) — public API for this merchant (orders, customers, …)
    const key = await partner.partners.crm.merchants.keys.create(tpa.accountId);
    crmKey = encrypt(key.token);
    console.log('CRM key:', key.name, key.prefix, '… (token stored)');
  }

  // 4. Persist on your side
  await yourDb.merchants.upsert({
    where: { id: input.externalRef },
    update: {
      tagadaMerchantId: tpa.accountId,
      tagadaTpaId: tpa.id,
      tagadaStoreId: storeId,
      tagadaProcessingKey: processingKey,
      tagadaCrmKey: crmKey,
    },
    create: {
      id: input.externalRef,
      tagadaMerchantId: tpa.accountId,
      tagadaTpaId: tpa.id,
      tagadaStoreId: storeId,
      tagadaProcessingKey: processingKey,
      tagadaCrmKey: crmKey,
      email: input.email,
    },
  });

  return tpa;
}

await onboardMerchant({
  externalRef: 'merchant_42',
  legalName: 'Acme SAS',
  country: 'FR',
  currency: 'EUR',
  email: 'ops@acme.com',
});
```

***

## File 2 — Charging server (Express / Next.js / Hono / whatever)

```ts theme={null}
// server/api/charge.ts
import Tagada from '@tagadapay/node-sdk';

export async function chargeHandler(req: Request) {
  const body = await req.json();
  const {
    merchantId,           // your merchant id
    tagadaToken,          // from the browser (card, apple_pay, or google_pay)
    paymentMethod,        // 'card' | 'apple_pay' | 'google_pay'
    amount,
    currency,
    customerData,
  } = body;

  // 1. Resolve merchant + decrypt their Processing Key
  const merchant = await yourDb.merchants.findUnique({ where: { id: merchantId } });
  const charging = new Tagada(decrypt(merchant.tagadaProcessingKey));

  // 2. Create payment instrument
  const { paymentInstrument, customer } = await charging.paymentInstruments.createFromToken({
    tagadaToken,
    storeId: merchant.tagadaStoreId,
    customerData,
  });

  // 3. Charge
  try {
    const { payment } = await charging.payments.process({
      paymentInstrumentId: paymentInstrument.id,
      storeId: merchant.tagadaStoreId,
      amount,
      currency,
      paymentMethod,
      mode: 'purchase',
      metadata: { yourOrderId: body.orderId },
    });

    if (payment.status === 'requires_action' && payment.requireAction === 'threeds') {
      return Response.json({
        status: 'threeds',
        challenge: payment.requireActionData,
        paymentId: payment.id,
      });
    }

    return Response.json({
      status: payment.status,
      paymentId: payment.id,
      customerId: customer.id,
    });
  } catch (err) {
    if (err.code === 'card_declined') {
      return Response.json({ status: 'declined', reason: err.failureReason }, { status: 402 });
    }
    throw err;
  }
}
```

***

## File 3 — Browser checkout page

```html theme={null}
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Acme — Checkout</title>
</head>
<body>
  <h1>Pay €49.99</h1>

  <!-- Express buttons (rendered conditionally based on paymentSetup) -->
  <div id="express-buttons"></div>

  <!-- Card form -->
  <form id="card-form">
    <input id="card-number" placeholder="Card number" />
    <input id="card-expiry" placeholder="MM/YY" />
    <input id="card-cvc" placeholder="CVC" />
    <button type="submit">Pay with card</button>
  </form>

  <script type="module">
    import {
      Tokenizer,
      isApplePayAvailable,
      startApplePaySession,
      isGooglePayAvailable,
      startGooglePaySession,
    } from 'https://esm.sh/@tagadapay/core-js@2';

    // 1. Discover what this merchant has enabled
    //    (your server fetches this with the merchant's Processing Key, then exposes
    //    the SAFE subset — never the publishable keys / processor IDs you don't trust on the client)
    const setup = await fetch('/api/payment-setup?merchant=merchant_42').then(r => r.json());
    const tokenizer = new Tokenizer({ environment: 'production' });

    // 2. Render Apple Pay button if native is wired
    if (setup.apple_pay?.enabled
        && setup.apple_pay.provider === 'apple_pay'
        && await isApplePayAvailable()) {
      const btn = document.createElement('apple-pay-button');
      btn.setAttribute('buttonstyle', 'black');
      btn.setAttribute('type', 'pay');
      btn.style.cursor = 'pointer';
      btn.onclick = () => {
        startApplePaySession(
          {
            basisTheoryApiKey: setup.apple_pay.metadata.basisTheoryApiKey,
            countryCode: 'FR',
            storeName: 'Acme',
          },
          { currency: 'EUR', totalAmountMinor: 4999 },
          {
            onSuccess: async (tagadaToken, contacts) => {
              await charge({
                tagadaToken,
                paymentMethod: 'apple_pay',
                customerData: {
                  email: contacts.shippingContact?.emailAddress,
                  firstName: contacts.shippingContact?.givenName,
                  lastName: contacts.shippingContact?.familyName,
                },
              });
            },
            onError: (err) => alert(err.message),
            onCancel: () => {},
          },
        );
      };
      document.getElementById('express-buttons').appendChild(btn);
    }

    // 3. Card form
    document.getElementById('card-form').addEventListener('submit', async (e) => {
      e.preventDefault();
      const tagadaToken = await tokenizer.tokenizeCard({
        cardNumber: document.getElementById('card-number').value,
        expiryDate: document.getElementById('card-expiry').value,
        cvc: document.getElementById('card-cvc').value,
      });
      await charge({
        tagadaToken,
        paymentMethod: 'card',
        customerData: { email: prompt('Email?') },
      });
    });

    async function charge({ tagadaToken, paymentMethod, customerData }) {
      const res = await fetch('/api/charge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          merchantId: 'merchant_42',
          tagadaToken,
          paymentMethod,
          amount: 4999,
          currency: 'EUR',
          customerData,
          orderId: 'order_' + Date.now(),
        }),
      });

      const result = await res.json();

      if (result.status === 'captured') {
        window.location = `/thank-you?payment=${result.paymentId}`;
      } else if (result.status === 'threeds') {
        // Embed result.challenge.url in an iframe and resume after callback
        renderThreeDsChallenge(result.challenge);
      } else if (result.status === 'declined') {
        alert(`Declined: ${result.reason}`);
      }
    }
  </script>
</body>
</html>
```

***

## Same flow for Klarna (redirect APM)

The browser doesn’t do anything other than the click + final redirect:

```ts theme={null}
// Browser
async function payWithKlarna() {
  const res = await fetch('/api/charge', {
    method: 'POST',
    body: JSON.stringify({
      merchantId: 'merchant_42',
      paymentMethod: 'klarna',
      amount: 4999,
      currency: 'EUR',
      customerData: { email: 'jane@example.com' },
      orderId: 'order_' + Date.now(),
    }),
  });
  const { redirectUrl } = await res.json();
  window.location.href = redirectUrl;
}
```

```ts theme={null}
// Server — extend the existing handler with the redirect APM path
if (paymentMethod === 'klarna') {
  // No tokenization, no payment instrument — just a customer + processor + redirect
  const customer = await charging.customers.upsertByEmail({
    storeId: merchant.tagadaStoreId,
    email: customerData.email,
  });

  const { payment } = await charging.payments.process({
    storeId: merchant.tagadaStoreId,
    customerId: customer.id,
    processorId: setup['klarna:stripe'].processorId,
    paymentMethod: 'klarna',
    amount,
    currency,
    mode: 'purchase',
    returnUrl: `https://your-platform.com/checkout/return?order=${body.orderId}`,
  });

  return Response.json({
    redirectUrl: payment.requireActionData.metadata.redirect.redirectUrl,
    paymentId: payment.id,
  });
}
```

***

## Webhook handler (settle status server-side)

```ts theme={null}
// server/api/webhooks/tagada.ts
import { verifyWebhookSignature } from '@tagadapay/node-sdk';

export async function tagadaWebhookHandler(req: Request) {
  const signature = req.headers.get('tagada-signature');
  const raw = await req.text();

  const event = verifyWebhookSignature({
    payload: raw,
    signature,
    secret: process.env.TAGADA_WEBHOOK_SECRET!,
  });

  switch (event.type) {
    case 'payment.succeeded':
      await yourDb.orders.update({
        where: { externalRef: event.data.metadata.yourOrderId },
        data: { status: 'paid', paymentId: event.data.id },
      });
      break;

    case 'payment.failed':
      await yourDb.orders.update({
        where: { externalRef: event.data.metadata.yourOrderId },
        data: { status: 'failed', failureReason: event.data.failureReason },
      });
      break;

    case 'payment.refunded':
      // ...
      break;
  }

  return new Response('ok');
}
```

***

## File 4 — read a merchant's transactions, orders & customers

Onboarding and charging is only half the job — your merchants also want to **see their own activity** inside your product. You read it back with the same two keys you stored in File 1, on behalf of one merchant at a time.

Two layers are available, depending on what you need:

* **CRM layer** (CRM Key, `sk_crm_…`) — the merchant's business objects: orders, payments (with their transactions), customers, subscriptions. This is what you render in a merchant-facing dashboard.
* **Processing layer** (Processing Key, `tp_sk_…`) — the raw acquirer-side charges/transactions for the merchant's TPA: scheme, 3DS outcome, auth code, BIN intelligence, refunds, disputes. This is what you use for reconciliation and risk.

```ts theme={null}
// server/api/merchant-activity.ts
import Tagada from '@tagadapay/node-sdk';

export async function merchantActivity(merchantId: string) {
  const merchant = await yourDb.merchants.findUnique({ where: { id: merchantId } });

  // ── CRM layer: orders, payments+transactions, customers ────────────────
  const crm = new Tagada(decrypt(merchant.tagadaCrmKey)); // sk_crm_live_…

  // Orders (POST /orders/list under the hood → rich filter + sort)
  const orders = await crm.orders.list({
    pagination: { page: 1, pageSize: 25 },
    sortBy: { field: 'orders.createdAt', direction: 'desc' },
    filters: { 'orders.status': ['paid'] },        // optional
  });
  console.log(orders.total, 'orders');

  // Payments — each payment carries its `transactions[]` (the actual attempts)
  const payments = await crm.payments.list({ pagination: { page: 1, pageSize: 25 } });
  for (const p of payments.data) {
    console.log(p.id, p.amount, p.currency, p.status, p.processorName);
    for (const tx of p.transactions ?? []) {
      console.log('  ↳', tx.type, tx.status, tx.amount, tx.processorId);
    }
  }

  // Every transaction for ONE of the merchant's customers
  const forCustomer = await crm.payments.list({
    pagination: { page: 1, pageSize: 50 },
    filters: { customer: { email: 'buyer@example.com' } },
  });

  // Customers
  const customers = await crm.customers.list({ pageSize: 25 });

  // ── Processing layer: raw acquirer charges for the merchant's TPA ───────
  // Skip this block for CRM-only merchants (no TPA).
  if (merchant.tagadaTpaId) {
    const proc = new Tagada(decrypt(merchant.tagadaProcessingKey)); // tp_sk_live_…

    const charges = await proc.processing.charges.list({
      account: merchant.tagadaTpaId,
      status: 'captured',              // optional
      from: '2026-07-01T00:00:00Z',    // optional
      limit: 50,
    });
    for (const c of charges.data) {
      console.log(c.id, c.amountMinor, c.currency, c.status);
    }
    if (charges.hasMore) {
      await proc.processing.charges.list({
        account: merchant.tagadaTpaId,
        cursor: charges.nextCursor!,
      });
    }

    // Full detail on one charge: scheme, 3DS, auth code, BIN, CRM ids
    const detail = await proc.processing.charges.retrieve(charges.data[0].id, {
      account: merchant.tagadaTpaId,
    });
    console.log(detail.paymentMethodVariant, detail.threeDSecure, detail.authCode);
    console.log(detail.orderId, detail.customerId); // deep-link back to the CRM

    // KPIs merchants ask for (volume, auth rate, avg ticket, fees), 30-day window
    const stats = await proc.processing.tpas.getStats(merchant.tagadaTpaId, { days: 30 });
    console.log(`auth rate: ${(stats.authRateBps ?? 0) / 100}%`);
  }

  return { orders, payments, customers };
}
```

Every CRM listing returns the same envelope — `{ data, total, page, pageSize, totalPages, hasMore }` — and every processing listing is cursor-paginated (`{ data, hasMore, nextCursor }`). Retrieve single records with `orders.retrieve(id)`, `payments.retrieve(id)`, `customers.retrieve(id)` on the CRM side, and `charges.retrieve(id, { account })` on the processing side.

<Note>
  **Which key for which read?** Use the **CRM Key** for the merchant-facing view (orders, payments, customers, subscriptions) and the **Processing Key** for acquirer-level charges, refunds, disputes and payouts. Full method lists: [CRM provisioning → Query the merchant's data](/developer-tools/partners/merchants#query-the-merchants-data) and [Reporting, refunds & disputes](/developer-tools/partners/reporting).
</Note>

***

## What you have now

A complete embedded-payments integration:

| Capability                                 | File                                                     |
| ------------------------------------------ | -------------------------------------------------------- |
| Provision merchants                        | `scripts/onboard-merchant.ts`                            |
| Per-merchant API key isolation             | Processing Key (+ optional CRM Key) encrypted in your DB |
| Card payments (S2S)                        | server `/api/charge` + browser tokenization              |
| Apple Pay (native)                         | browser button + server route                            |
| Klarna / iDEAL (redirect)                  | server `/api/charge` redirect path                       |
| 3DS challenge handling                     | `requireAction === 'threeds'` branch                     |
| Async settlement                           | webhook handler                                          |
| Read back orders, transactions & customers | server `/api/merchant-activity`                          |

No checkout sessions, no funnels, no Tagada-hosted UI. Your platform, your brand, our payments.
