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

# Let Signed-In Customers Manage Orders Without Email Verification

> Send customers who are signed in on your site straight into your store's customer portal

## What You'll Build

A **Manage orders** button on your site. When a signed-in customer clicks it, your server asks Waffo Pancake for a portal link and redirects the browser there. The customer lands in your store's customer portal, already signed in, with no email or verification code.

```mermaid theme={"system"}
sequenceDiagram
    participant C as Customer Browser
    participant S as Your Server
    participant W as Waffo Pancake API
    participant P as Customer Portal

    C->>S: Click "Manage orders"
    S->>S: Read the customer from your own session
    S->>W: Create portal link (API Key)
    W->>S: portalUrl, expiresAt
    S->>C: 302 redirect to portalUrl
    C->>P: Portal for this customer, this store
```

In the portal the customer can:

| Area | Available |
| - | - |
| Orders and payments | View |
| Subscriptions | View, cancel, reactivate, change plan |
| Invoices | View |
| Refund tickets | View only — creating or resubmitting a refund request is not available in this version |

<Note>
  Customers who arrive this way cannot request refunds from the portal. Handle refund requests through your own support channel, then refund from the Dashboard or with the [Create Refund Ticket](/api-reference/endpoints/refunds/create-refund-ticket) API.
</Note>

***

## Prerequisites

* An API Key (Dashboard → API & Development). Use a test key while you build
* The Store ID (`STO_xxx`) of the store whose portal the customer should see
* Orders created with [authenticated checkout](/integrate/sdks#authenticated-checkout-recommended), so they carry your `buyerIdentity`
* Your own sign-in: this flow trusts your server's session, so the customer must already be authenticated on your site
* If you use an SDK: `@waffo/pancake-ts` 0.26.0 or later, `@waffo/pancake-nextjs` 0.9.0 or later, or the [Go SDK](https://pkg.go.dev/github.com/waffo-com/waffo-pancake-sdk-go) v0.17.0 or later
* Your store's **Website** set in [Store Settings](/settings/store-settings). When a portal session ends, the portal offers a **Return to merchant site** button that links only to this address; if no website is set, the button is hidden

***

## Step 1: Choose the Customer Identity

The portal shows the orders whose `buyerIdentity` equals the value you pass here, in the store you name. Use **the same stable value you pass when creating orders**.

* **Recommended:** your internal customer ID — stable, and never reassigned to another person
* Avoid email addresses: a customer who changes email would lose sight of earlier orders, and a recycled address could expose them to someone else
* Orders placed anonymously, or under a different identity, **do not appear**. Waffo Pancake does not merge identities; if you switch identifiers, earlier orders stay under the old value

<Warning>
  Take `buyerIdentity` and `storeId` from your server's own signed-in session — never from a query parameter, form field, header, or cookie value the browser can set. Whoever controls `buyerIdentity` gets that customer's portal. Waffo Pancake trusts your server and does not verify the customer's email.
</Warning>

***

## Step 2: Create the Link on Your Server

Call the API from your backend only. The API Key must never reach the browser.

* **Environment follows the API Key.** A test key opens the portal with test data; a production key opens it with production data. There is no environment parameter
* **Create the link on every click**, right before redirecting. Do not cache it, store it, or send it by email
* **Redirect with 302.** Add `Cache-Control: no-store` and `Referrer-Policy: no-referrer` to the redirect response, so the tokenized link is neither cached nor passed on as a referrer

<CodeGroup>
  ```typescript Next.js (Route Handler) theme={"system"}
  // app/account/portal/route.ts
  import { NextResponse } from "next/server";
  import { WaffoPancake } from "@waffo/pancake-ts";
  import { getSignedInUser } from "@/lib/auth"; // your own session check

  const client = new WaffoPancake({
    merchantId: process.env.WAFFO_MERCHANT_ID!,
    privateKey: process.env.WAFFO_PRIVATE_KEY!,
  });

  export async function GET(request: Request) {
    const user = await getSignedInUser(request);
    if (!user) {
      return NextResponse.redirect(new URL("/login", request.url), 302);
    }

    try {
      // portalUrl already includes #token=...
      const { portalUrl } = await client.auth.createCustomerPortalLink({
        storeId: process.env.WAFFO_STORE_ID!,
        buyerIdentity: user.id, // same value used at checkout
      });

      const response = NextResponse.redirect(portalUrl, 302);
      response.headers.set("Cache-Control", "no-store");
      response.headers.set("Referrer-Policy", "no-referrer");
      return response;
    } catch (err) {
      // Log the error, never the portal URL
      console.error("customer portal link failed", err);
      return new NextResponse("The customer portal is unavailable. Please try again.", { status: 502 });
    }
  }
  ```

  ```typescript Next.js (SDK helper) theme={"system"}
  // app/account/portal/route.ts
  // Signature summary — see the @waffo/pancake-nextjs reference for the exact types
  import { createCustomerPortalRedirect } from "@waffo/pancake-nextjs/server";
  import { getSignedInUser } from "@/lib/auth"; // your own session check

  export const GET = createCustomerPortalRedirect(
    {
      merchantId: process.env.WAFFO_MERCHANT_ID!,
      privateKey: process.env.WAFFO_PRIVATE_KEY!,
    },
    {
      // Resolve the customer from YOUR session, never from the request body or query
      resolveAuthenticatedCustomer: async (request) => {
        const user = await getSignedInUser(request);
        return user ? { storeId: process.env.WAFFO_STORE_ID!, buyerIdentity: user.id } : null;
      },
      // Fixed path you control; without loginUrl, signed-out visitors get a 401
      loginUrl: "/login?returnTo=/account/portal",
    },
  );
  ```

  ```typescript Node.js (Express) theme={"system"}
  import express from "express";
  import { WaffoPancake } from "@waffo/pancake-ts";

  const client = new WaffoPancake({
    merchantId: process.env.WAFFO_MERCHANT_ID!,
    privateKey: process.env.WAFFO_PRIVATE_KEY!,
  });

  const app = express();

  // req.user is set by your own auth middleware; typed here for the example
  type SignedInRequest = express.Request & { user?: { id: string } };

  app.get("/account/portal", async (req: SignedInRequest, res) => {
    const user = req.user;
    if (!user) return res.redirect(302, "/login");

    try {
      const { portalUrl } = await client.auth.createCustomerPortalLink({
        storeId: process.env.WAFFO_STORE_ID!,
        buyerIdentity: user.id,
      });
      res.set("Cache-Control", "no-store");
      res.set("Referrer-Policy", "no-referrer");
      res.redirect(302, portalUrl);
    } catch (err) {
      // Log the error, never the portal URL
      console.error("customer portal link failed", err);
      res.status(502).send("The customer portal is unavailable. Please try again.");
    }
  });
  ```

  ```go Go theme={"system"}
  // Snippet, not a full program: mount portalHandler on your own server.
  // signedInUser is your own session lookup, e.g.
  //   func signedInUser(r *http.Request) (user User, ok bool)
  // Exact types: https://pkg.go.dev/github.com/waffo-com/waffo-pancake-sdk-go
  import (
  	"log"
  	"net/http"
  	"os"

  	pancake "github.com/waffo-com/waffo-pancake-sdk-go"
  )

  func portalHandler(client *pancake.Client) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		user, ok := signedInUser(r) // your own session check
  		if !ok {
  			http.Redirect(w, r, "/login", http.StatusFound)
  			return
  		}

  		link, err := client.Auth.CreateCustomerPortalLink(r.Context(), pancake.CreateCustomerPortalLinkParams{
  			StoreID:       os.Getenv("WAFFO_STORE_ID"),
  			BuyerIdentity: user.ID, // same value used at checkout
  		})
  		if err != nil {
  			log.Printf("customer portal link failed: %v", err) // never log link.PortalURL
  			http.Error(w, "The customer portal is unavailable. Please try again.", http.StatusBadGateway)
  			return
  		}

  		// link.PortalURL already includes #token=...
  		w.Header().Set("Cache-Control", "no-store")
  		w.Header().Set("Referrer-Policy", "no-referrer")
  		http.Redirect(w, r, link.PortalURL, http.StatusFound)
  	}
  }
  ```
</CodeGroup>

The SDK helper `createCustomerPortalRedirect` takes these options:

| Option | Required | Behavior |
| - | - | - |
| `resolveAuthenticatedCustomer` | Yes | Returns `{ storeId, buyerIdentity }` from your own session, or `null` when no one is signed in |
| `loginUrl` | No | Where signed-out visitors are sent with a 302. Without it, they get a `401` JSON response |
| `onError` | No | Handles a failed link creation. The default is a generic `502` JSON response; use it to log the error (never the portal URL) or return your own page |

Every response the helper builds carries `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. A response you return from `onError` is your own — set both headers on it as well.

<Warning>
  Set `loginUrl` to a path you control. Never build it by copying request parameters (such as a `returnTo` query value) verbatim — that turns your portal route into an open redirect.
</Warning>

Not using an SDK? Call [Issue Session Token](/api-reference/endpoints/auth/issue-session-token) with `purpose: "portal"` and `storeId`, then redirect to `portalUrl + "#token=" + token`.

***

## Step 3: Add the Button

Point the button at your own route, not at Waffo Pancake. The browser never sees the API Key, and the link is minted only when the customer clicks.

```tsx theme={"system"}
// A plain <a>, not next/link: prefetching would create portal links nobody clicked
const PORTAL_ROUTE = "/account/portal"; // the route from Step 2

export function ManageOrdersButton() {
  return <a href={PORTAL_ROUTE}>Manage orders</a>;
}
```

***

## How the Session Behaves

| Behavior | Detail |
| - | - |
| Lifetime | 15 minutes of inactivity. Each action in the portal extends it |
| Expired session | The portal shows a re-entry page asking the customer to go back to your site, with a **Return to merchant site** button if your store's website is set. A new click on **Manage orders** creates a fresh link — no email code |
| Reusing a link | The link is **not single-use**: it can be opened again while the session is alive. Treat it as a password |
| One portal per browser | A browser holds one portal session at a time. Entering from your site replaces any earlier portal session in that browser, including one opened with an email Magic Link or for another store. An older tab of the replaced session shows **Session switched** and moves to the current session |
| Scope | One store, one environment, one `buyerIdentity`. Other stores and the other environment are not visible |

<Warning>
  The `#token=` part of the link is a bearer credential. Do not write the link to logs, analytics, error trackers, or support tickets, and do not share it in email or chat.
</Warning>

***

## Test Before Going Live

<Steps>
  <Step title="Create a test order">
    With your test API Key, place an order through authenticated checkout using a test customer's `buyerIdentity`.
  </Step>

  <Step title="Open the portal">
    Sign in to your site as that customer and click **Manage orders**. The test order should be listed.
  </Step>

  <Step title="Check the identity boundary">
    Sign in as a different customer and confirm the first customer's order does not appear. Try adding another customer's ID to the request — your route must ignore it.
  </Step>

  <Step title="Switch to production">
    Deploy with your production API Key. Links now open production data.
  </Step>
</Steps>

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="The portal opens but shows no orders">
    1. Check that the orders were created with the same `buyerIdentity` string (exact match, including case)
    2. Check that the orders belong to the `storeId` you passed
    3. Check the API Key's environment: a test key shows only test orders, a production key only production orders
    4. Orders from anonymous checkout carry no `buyerIdentity` and never appear here
  </Accordion>

  <Accordion title="The portal says the session has ended">
    The portal shows **Please re-enter from the merchant's site** when the session was idle for more than 15 minutes, or when the portal was opened for a different store than the session in this browser, without a new link. Send the customer back through **Manage orders** on your site to get a new link.
  </Accordion>

  <Accordion title="The portal says the session was switched">
    The customer entered the portal again in the same browser — from your site in another tab, from another store's portal, or with an email Magic Link. The newest entry becomes the browser's portal session; older tabs show **Session switched** and move to the current session. No action is needed.
  </Accordion>

  <Accordion title="403 Environment mismatch from the Customer Portal API">
    You called the Customer Portal API with a portal token and an `X-Environment` that differs from the API Key's environment. Send the key's environment.
  </Accordion>
</AccordionGroup>

***

## Checklist

* [ ] The link is created on your server; the API Key never reaches the browser
* [ ] `buyerIdentity` comes from your own signed-in session and matches the value used at checkout
* [ ] `storeId` comes from your server configuration, not the request
* [ ] The route responds with a 302, `Cache-Control: no-store` and `Referrer-Policy: no-referrer`
* [ ] Portal links and tokens are never logged or stored
* [ ] Refund requests have a support path outside the portal

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Issue Session Token" icon="key" href="/api-reference/endpoints/auth/issue-session-token">
    Request and response fields for `purpose: "portal"`.
  </Card>

  <Card title="Customer Portal" icon="user" href="/features/customer-portal">
    What customers see in the portal.
  </Card>
</CardGroup>


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