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

# Customer Portal

> Build your own account area: email-code login, subscriptions, cancellation with retention offers, addresses, cards, credits and re-orders

# Customer Portal

`tagada.portal` lets you build the customer account area of your store in your own code: the customer logs in with a code sent by email, then sees their orders and subscriptions and manages them — skip, pause, change frequency, swap product, cancel with your retention offers, update addresses and cards, order again, spend credits.

It is the same API as the portal TagadaPay hosts on `/account` of your checkout domain. Both read the portal you configure in the CRM under **Customer portal**: which actions are allowed, per store and per product, the cancellation reasons and the retention offer of each, the membership program. A rule you change there applies to your headless portal too.

**Requirements**

* `@tagadapay/headless-sdk` **1.17.0** or later.
* A client created **without** `apiKey`. The SDK sends the API key in place of the customer's session when one is set, and the portal then refuses every call.

***

## Log in

```typescript theme={null}
import { createHeadlessClient } from '@tagadapay/headless-sdk';

const tagada = createHeadlessClient({ storeId: 'store_abc123' });

await tagada.portal.requestCode('jane@example.com');      // emails a 6-digit code
const { token } = await tagada.portal.verifyCode('jane@example.com', code);
```

After `verifyCode`, the client sends the session with every call. The session lives in the client only: keep `token` yourself (for example in `sessionStorage`) and give it back after a page reload with `tagada.setSessionToken(token)`.

`requestCode` answers `404` when the store has no customer with that email.

***

## Read the portal

```typescript theme={null}
const config = await tagada.portal.getConfig(); // no login needed
const me = await tagada.portal.getMe();
```

| Call | Returns |
| - | - |
| `getConfig()` | `store`, `portal` (the design you published), `retention` (cancellation reasons, offers, confirmation copy), `membership` (the program, or `null`), `products` (those the portal can offer: swaps, member products, credit prices, packs). |
| `getMe()` | `customer`, `orders` (newest first), `subscriptions`, `membership`, `creditLedger`. |
| `getCredits()` | The customer's credit balance and ledger. |

***

## Manage a subscription

The everyday actions live on `tagada.customer` and return nothing; the newer ones live on `tagada.portal` and return `{ subscriptionId, status }`. Both need the logged-in session.

| Call | What it does |
| - | - |
| `customer.skipSubscription(id)` | Defers the next charge by one billing period. Active or trialing subscriptions only. |
| `portal.skipDate(id, 'YYYY-MM-DD')` / `portal.unskipDate(id, date)` | Skips one upcoming delivery date, or takes it back. Skipping the next date moves the next charge. |
| `customer.pauseSubscription(id, { resumeAt? })` | Pauses billing; `resumeAt` (ISO date) resumes it that day, within the pause limit you set (90 days by default). |
| `customer.resumeSubscription(id)` | Resumes a paused subscription, or one set to cancel at period end. |
| `customer.rescheduleSubscription(id, date)` | Moves the next billing date, in the future and within a year. |
| `customer.updateSubscriptionQuantity(id, n)` | Units per delivery, 1 to 100. |
| `customer.changeSubscriptionFrequency(id, priceId \| { optionId })` | Another frequency of the same product: a recurring price id, or a purchase option. |
| `customer.swapSubscriptionProduct(id, { productId, priceId } \| { productId, optionId, variantId? })` | Another product, at one of its recurring prices or purchase options. |
| `portal.addToNextOrder({ subscriptionId, productId, variantId, quantity })` | Ships a one-time item with the next renewal, charged with it. `quantity: 0` takes it off. |
| `portal.sendNow(id)` | Charges and ships the next order today. |
| `portal.reactivateSubscription(id)` | Restarts a cancelled subscription. |
| `portal.setSubscriptionCard(id, paymentMethodId)` | Bills the subscription on another saved card. |
| `customer.updateSubscriptionPaymentMethod(id, tagadaToken)` | Bills it on a new card tokenized with `tagada.payment.tokenizeCard`. |
| `portal.setSubscriptionAddress(id, addressId \| null)` | Ships it to an address of the book; `null` follows the default address. |
| `portal.retryPayment(id)` | Charges a past-due subscription again now. |
| `customer.cancelSubscription(id, { cancelAtPeriodEnd? })` | Cancels, at the end of the paid period by default. To offer your retention offers first, use the [cancel flow](#cancel-with-retention-offers) instead. |

`sendNow` and `retryPayment` answer `status: 'past_due'` while the charge settles; read `getMe()` again to see the outcome.

### Permissions

Every action can be turned off in the CRM, for the store or for one product; they are all on by default. A refused action throws `TagadaAuthError` with a message that says why ("This store does not offer this change for this product"). A missing or expired login throws the same class, so read `error.message` before sending the customer back to the login screen.

A subscription with a minimum number of paid orders cannot be cancelled before it reaches it:

```typescript theme={null}
import { TagadaValidationError } from '@tagadapay/headless-sdk';

try {
  await tagada.customer.cancelSubscription(subscriptionId);
} catch (error) {
  if (error instanceof TagadaValidationError && error.appCode === 'MINIMUM_ORDERS_NOT_REACHED') {
    // error.message says how many paid orders the subscription still needs
  }
}
```

***

## Cancel with retention offers

`getConfig().retention` holds the cancellation reasons you configured, and the retention offer mapped to each (a discount, a skip, a pause, another frequency, a product swap, a yearly plan). The flow is yours to draw; the SDK applies the outcome:

```typescript theme={null}
const { reasons, offers } = (await tagada.portal.getConfig()).retention;
const target = { type: 'subscription', subscriptionId } as const;

// The customer picked a reason that has an offer, and took it:
await tagada.portal.acceptCancelOffer({ target, reasonId: reason.id, offerId: reason.offerId! });

// Or they confirmed the cancellation:
await tagada.portal.cancelFlow({ target, reasonId: reason.id });
```

* `target` is `{ type: 'subscription', subscriptionId }` or `{ type: 'membership' }`.
* `acceptCancelOffer` also takes what the customer chose inside the offer: `optionId`, `productId`, `variantId`, or `days` for a pause.
* Both return `{ subscription, membership }` as they stand after the change.

`portal.switchOption({ target, optionId })` moves a subscription to another purchase option, or the membership to `'monthly'` / `'yearly'`, **charged now**. It returns the `orderId` of that charge and `status: 'paid'` or `'requires_action'` (see [3D Secure](#3d-secure)); with `requires_action`, the subscription is unchanged until the customer completes it.

***

## Account

| Call | What it does |
| - | - |
| `portal.updateProfile({ firstName, lastName, email, phone, emailCode? })` | Saves name, email and phone. `phone: null` keeps the one on file. To change the email, call `portal.requestEmailChangeCode(newEmail)` first and pass the code it emails as `emailCode`. |
| `portal.saveAddress(address)` | Adds an address, or replaces the one with that `id`. `isDefault: true` makes it the default. Returns `{ addressId }`. |
| `portal.removeAddress(id)` / `portal.setDefaultAddress(id)` | — |
| `portal.addCard({ tagadaToken, makeDefault })` | Saves a card tokenized with `tagada.payment.tokenizeCard`. Returns `{ paymentMethodId }`. |
| `portal.removeCard(id)` | Refused while a live subscription is paid with that card. |
| `portal.setDefaultPaymentMethod(id)` | The default card also becomes the card of every live subscription. |

***

## Buy from the portal

These charge the customer's default card and ship to their default address.

| Call | What it does |
| - | - |
| `portal.buyAgain([{ variantId, quantity }])` | Orders products again at today's one-time price. |
| `portal.buyNow(variantId, quantity?)` | One product, now. |
| `portal.joinMembership('monthly' \| 'yearly')` | Buys the membership. |
| `portal.redeemCredits({ productId, variantId, quantity })` | Pays a product with credits. Not enough credits: HTTP `402`. |
| `portal.buyCreditPack({ packId })` | Buys a credit pack with the saved card. No saved card: `422`. |
| `portal.getOffer(token)` / `portal.acceptOffer({ token })` | The upsell of a signed link from your emails (`/account/offer/<token>`), paid with the card of the original order. Accepting twice charges once. |

They return the `orderId` and a `status` of `'paid'` or `'requires_action'`.

***

## 3D Secure

When the card asks for authentication, a charging call returns `status: 'requires_action'` and a `redirectUrl`. Send the customer there to complete it.

```typescript theme={null}
const result = await tagada.portal.buyNow(variantId);
if (result.status === 'requires_action' && result.redirectUrl) {
  window.location.href = result.redirectUrl;
}
```

***

## Errors

| HTTP | Thrown as | Usually means |
| - | - | - |
| `401`, `403` | `TagadaAuthError` | Not logged in, session expired, or the action is turned off. Read `error.message`. |
| `404` | `TagadaNotFoundError` | Unknown customer email, subscription, address or card. |
| `422` | `TagadaValidationError` | A rule refused the change; `error.appCode` names it (`MINIMUM_ORDERS_NOT_REACHED`). |
| `402` and others | `TagadaError` | `error.statusCode` and `error.message` say what happened (`402`: not enough credits). |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.