Skip to main content
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:
app/link/[token]/page.tsx
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:
app/api/auth/link/route.ts
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:
PurposeCustomer ReceivesRedirect PathTokens Provided?
view-appointmentLink from confirmation email/account/appointments/{id}/viewYes (auto sign-in)
cancel-appointmentLink to cancel or reschedule/account/appointments/{id}/view?action=cancelYes (auto sign-in)
reschedule-appointmentLink to reschedule/account/appointments/{id}/view?action=rescheduleYes (auto sign-in)
leave-feedbackLink to leave a review/account/appointments/{id}/view?action=feedbackYes (auto sign-in)
verify-emailLink to confirm email address/accountYes (auto sign-in)
reset-passwordLink 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

app/auth/reset-password/page.tsx
"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:
// 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:
MethodPurpose
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 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.
Last modified on June 21, 2026