Customer auth
Email/password signup, login, refresh, logout for storefront customers.
Storefront customers authenticate with email + password per shop. Each shop has its own customer base; the same email can register at two shops independently. Google OAuth is in flight; OTP-via-SMS is currently panel-side only and not exposed through this API.
All four endpoints require a publishable key. Login/signup additionally trigger rate limits per source IP.
Signup
POST /api/v1/store/auth/signupRequest body
{
"name": "Rafiul Hassan",
"email": "rafiul@example.com",
"password": "correct horse battery staple",
"phoneNumber": "+8801711000000"
}| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–100 chars. |
email | string | yes | Lowercased and trimmed server-side. |
password | string | yes | Min 8 chars. Stored as Argon2id hash. |
phoneNumber | string | no | E.164 format. |
Response
HTTP/1.1 201 Created
{
"customer": {
"id": "u1b2c3d4-...",
"name": "Rafiul Hassan",
"email": "rafiul@example.com",
"phoneNumber": "+8801711000000",
"imageUrl": null,
"createdAt": "2026-05-27T14:00:00.000Z"
},
"tokens": {
"accessToken": "eyJhbGciOiJIUzI1Ni...",
"accessTokenExpiresAt": "2026-05-27T15:00:00.000Z",
"refreshToken": "eyJhbGciOiJIUzI1Ni...",
"refreshTokenExpiresAt": "2026-06-26T14:00:00.000Z"
}
}Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_body | Validation failed. |
| 409 | email_exists | A customer with that email already exists in this shop. |
| 429 | rate_limited | 5 signups per minute per IP. |
Login
POST /api/v1/store/auth/loginRequest body
{
"email": "rafiul@example.com",
"password": "correct horse battery staple"
}Response
Same shape as signup.
Errors
| Status | Code | When |
|---|---|---|
| 401 | invalid_credentials | Wrong email or password. Generic to prevent enumeration. |
| 429 | rate_limited | 10 attempts per minute per IP. |
| 423 | account_locked | Too many failed attempts. Locked for 15 minutes. |
Refresh
POST /api/v1/store/auth/refreshExchange the refresh token for a new access + refresh pair. Refresh tokens are single-use.
Request body
{ "refreshToken": "eyJhbGciOiJIUzI1Ni..." }Response
{
"tokens": {
"accessToken": "...",
"accessTokenExpiresAt": "...",
"refreshToken": "...",
"refreshTokenExpiresAt": "..."
}
}Errors
401 invalid_customer_token with reason:
expired— refresh token TTL elapsed (30 days). Customer must re-login.revoked— the token chain was revoked.replayed— the refresh token was already exchanged. The entire token family is now revoked. Treat as credential compromise. The customer must re-login.invalid— token doesn't parse or signature mismatch.
Logout
POST /api/v1/store/auth/logoutRevoke the customer's refresh token chain server-side. The access token will continue to work until natural expiry (up to 1 hour) — drop it client-side.
Request body
{ "refreshToken": "eyJhbGciOiJIUzI1Ni..." }Response
204 No Content.
What happens when an access token expires
API returns 401 invalid_customer_token with reason: "expired". Call
refresh, retry the original request with the new access token.
async function callAuthed(req: () => Promise<Response>) {
let res = await req()
if (res.status === 401) {
const { error } = await res.json()
if (error.code === "invalid_customer_token" && error.reason === "expired") {
await refresh()
res = await req()
}
}
return res
}Don't refresh proactively on a timer — clock skew across devices makes this brittle. Let the 401 trigger the refresh.