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

# Customer email links (magic links)

> Set up passwordless email links for customer actions: view/reschedule/cancel appointments, leave feedback, verify email, and reset passwords. No additional code — just configure one setting and implement one route.

Customer email links let your customers interact with their bookings directly from email. When Opencals sends an appointment confirmation, feedback request, or password reset, the link in the email takes them directly to the right page — signed in automatically.

This guide is for merchants building custom frontends with the Opencals Storefront SDK. If you use the multi-tenant Opencals storefront (salon.opencals.com), no setup is needed.

## Overview

All customer email links follow the same pattern:

* Every link is `{storefrontBaseUrl}/link/{token}`
* Your app implements one route: `GET /link/{token}`
* The route calls `Auth.resolveLink(token)` to get the customer's action and redirect target
* You sign the customer in (if tokens are provided) and redirect them

## Setup

### Step 1: Set the `storefrontBaseUrl` Setting

In the Opencals dashboard, go to **Settings → API → Storefront Base URL** and enter your app's base URL:

```
https://mybooking.example.com
```

This tells Opencals where to send customer email links. If you don't set it, Opencals will fall back to the store's domain, and links may not reach your custom frontend.

### Step 2: Implement the `/link/{token}` Route

Your app must handle `GET /link/{token}`. Here's an example with Next.js:

```ts app/link/[token]/page.tsx theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
import { redirect } from "next/navigation";
import { setupOpencals, AuthService } from "@opencals/storefront-sdk";

// Ensure SDK is initialized (your server config should call setupOpencals once at startup)
setupOpencals();

export default async function MagicLinkPage({ params }: { params: { token: string } }) {
  try {
    // Resolve the token: validates it, signs the customer in, and returns redirect info
    const { data: response } = await AuthService.resolveLink({
      query: { token: params.token }
    });

    if (!response) {
      return <div>Invalid or expired link</div>;
    }

    const { redirectPath, accessToken, refreshToken, requiresAction } = response;

    // If tokens are provided, save them for future requests
    if (accessToken && refreshToken) {
      // Option 1: Store in an httpOnly cookie (recommended)
      // You can set this via a server action or route handler
      
      // Option 2: Redirect to a handler that stores tokens and redirects
      // This avoids passing tokens in the URL
    }

    // Redirect the customer to their action
    redirect(redirectPath || "/account");

  } catch (error) {
    return <div>This link has expired or is invalid</div>;
  }
}
```

For production, store the tokens securely (httpOnly cookies) and redirect via a handler:

```ts app/api/auth/link/route.ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
import { cookies } from "next/headers";
import { setupOpencals, AuthService } from "@opencals/storefront-sdk";

setupOpencals();

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const token = searchParams.get("token");

  if (!token) {
    return new Response("Missing token", { status: 400 });
  }

  try {
    const { data: response } = await AuthService.resolveLink({
      query: { token }
    });

    if (response?.accessToken) {
      const cookieStore = await cookies();
      cookieStore.set("accessToken", response.accessToken, {
        httpOnly: true,
        secure: true,
        sameSite: "lax",
        maxAge: 3600, // 1 hour
      });
      if (response.refreshToken) {
        cookieStore.set("refreshToken", response.refreshToken, {
          httpOnly: true,
          secure: true,
          sameSite: "lax",
          maxAge: 604800, // 7 days
        });
      }
    }

    return Response.redirect(response?.redirectPath || "/account");

  } catch (error) {
    console.error("Link resolution failed:", error);
    return new Response("Invalid or expired link", { status: 400 });
  }
}
```

## Purpose → Redirect Table

This table shows what each link type does and where the customer is redirected:

| Purpose                  | Customer Receives                | Redirect Path                                       | Tokens Provided?              |
| ------------------------ | -------------------------------- | --------------------------------------------------- | ----------------------------- |
| `view-appointment`       | Link from confirmation email     | `/account/appointments/{id}/view`                   | Yes (auto sign-in)            |
| `cancel-appointment`     | Link to cancel or reschedule     | `/account/appointments/{id}/view?action=cancel`     | Yes (auto sign-in)            |
| `reschedule-appointment` | Link to reschedule               | `/account/appointments/{id}/view?action=reschedule` | Yes (auto sign-in)            |
| `leave-feedback`         | Link to leave a review           | `/account/appointments/{id}/view?action=feedback`   | Yes (auto sign-in)            |
| `verify-email`           | Link to confirm email address    | `/account`                                          | Yes (auto sign-in)            |
| `reset-password`         | Link to reset forgotten password | `/auth/reset-password?token={token}`                | No (tokens passed separately) |

## Special Case: Password Reset

Password reset links do **not** return customer tokens. Instead, the token is passed as a query parameter so your app can verify it when the customer submits a new password.

### Example: Password Reset Handler

```ts app/auth/reset-password/page.tsx theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
"use client";

import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { AuthService } from "@opencals/storefront-sdk";

export default function ResetPasswordPage() {
  const searchParams = useSearchParams();
  const token = searchParams.get("token");
  const [password, setPassword] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  if (!token) {
    return <div>Invalid reset link</div>;
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);

    try {
      // Call the API with the token from the URL
      await AuthService.resetPassword({
        body: { token, newPassword: password }
      });

      // Password reset successful — redirect to sign-in
      window.location.href = "/auth/sign-in?success=Password%20reset%20successful";
    } catch (err: any) {
      setError(err.message || "Failed to reset password");
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="New password"
        required
      />
      {error && <div className="error">{error}</div>}
      <button type="submit" disabled={loading}>
        {loading ? "Resetting..." : "Reset Password"}
      </button>
    </form>
  );
}
```

## Passwordless Email Login (Optional)

In addition to email links, you can offer passwordless login with 6-digit codes:

```ts theme={"theme":{"light":"github-light","dark":"github-dark-default"}}
// 1. Customer requests a code
await AuthService.requestLoginCode({
  body: { email: "customer@example.com" }
});
// Email is sent with 6-digit code

// 2. Customer enters the code
const { data: tokens } = await AuthService.verifyLoginCode({
  body: { email: "customer@example.com", code: "123456" }
});

// 3. Tokens are returned — sign them in
```

The code expires in 5 minutes and allows 5 failed attempts before a new code must be requested.

## SDK Methods

The following SDK methods are used for email links and passwordless auth:

| Method                                                        | Purpose                                 |
| ------------------------------------------------------------- | --------------------------------------- |
| `AuthService.resolveLink({ query: { token } })`               | Validate and resolve a magic link token |
| `AuthService.resetPassword({ body: { token, newPassword } })` | Reset password with token               |
| `AuthService.requestLoginCode({ body: { email } })`           | Send 6-digit code to email              |
| `AuthService.verifyLoginCode({ body: { email, code } })`      | Verify code and get tokens              |

See the [API reference](/docs/api-reference/introduction) for full request and response schemas.

## Security Checklist

* Store API keys in environment variables — never in client code.
* Call `resolveLink` on the server, not the client.
* Store customer tokens in **httpOnly cookies** (not localStorage) when possible.
* Redirect after storing tokens to avoid exposing them in the URL.
* For password reset, store the token as a query parameter only (it does not grant authentication).
* Tokens expire according to their purpose (1 hour for verify-email/reset-password, 7 days for leave-feedback, 24 hours default).

## Troubleshooting

**"Invalid or expired link"**

* The token may have been used already (one-time use only).
* The token may have expired (check the purpose expiry in the earlier table).
* The store context may not match the API key's store.

**Customer not signed in after clicking the link**

* Verify tokens were stored (httpOnly cookies, session, etc.).
* Check that `AuthService.resolveLink` returned `accessToken` and `refreshToken`.
* For `reset-password` links, no sign-in occurs — the customer must reset their password and sign in manually.

**Password reset token not working**

* The token is included in the URL query string only, not in the API call.
* Pass it to `AuthService.resetPassword({ body: { token, newPassword } })` exactly as received.
