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:
| 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
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:
| 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 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