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
Once you have a sub-key + 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:
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.
PCI scope-out. Because the PAN never traverses your stack, your PCI obligations stay at SAQ-A. See @tagadapay/core-js for the full tokenization API.
Step 2 — Create a payment instrument (server)
Convert the single-use tagadaToken into a reusable paymentInstrument:
const charging = new Tagada(process.env.MERCHANT_42_SUBKEY!);
const { paymentInstrument, customer } = await charging.paymentInstruments.createFromToken({
tagadaToken,
storeId: 'store_xxx',
customerData: {
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Doe',
phone: '+33612345678',
billingAddress: {
line1: '10 rue de Rivoli',
city: 'Paris',
postalCode: '75001',
country: 'FR',
},
},
});
// {
// 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.
Step 3 — 3DS (only when required)
For European cards or when an issuer requests SCA, run a 3DS challenge:
const threeds = await charging.threeds.createSession({
provider: 'basis_theory',
storeId: 'store_xxx',
paymentInstrumentId: paymentInstrument.id,
sessionData: {
/* device info collected client-side via core-js */
},
});
// If status === 'challenge_required':
// - Render an iframe pointing to threeds.challengeUrl
// - The challenge happens in the customer's browser
// - On completion, the SDK fires a callback with the verified result
// If status === 'frictionless':
// - No challenge needed, proceed directly to charge
When do you need 3DS? When the underlying card flow has threeDsEnabled: true (configured per-merchant via Payment Flows), or the processor returns a requireAction: 'threeds' from the charge call. The SDK can run 3DS proactively (before the charge) or reactively (after the charge fails with the action). The reactive path is simpler and is what most partners use — just call payments.process and handle the action response.
Step 4 — Charge
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
metadata: {
yourOrderId: 'order_42',
// any free-form key-value
},
});
// {
// 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
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 'requires_action':
if (payment.requireAction === 'threeds') {
// Render the 3DS challenge using payment.requireActionData
// Then re-call payments.process with the result
} else if (payment.requireAction === 'redirect') {
// Redirect APM (Klarna etc.) — see the APMs page
window.location = payment.requireActionData.metadata.redirect.redirectUrl;
}
break;
case 'failed':
// payment.failureReason is structured: 'do_not_honor', 'insufficient_funds', etc.
break;
}
Auth + capture (split-flow)
Authorize now, capture later (hotels, car rentals, pre-orders, marketplaces):
// 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:
await charging.payments.refund('pay_xxx');
Partial refund:
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:
// 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
// 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 },
});
Sub-keys see only payments belonging to their 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:
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 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:
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;
}