UniSave uses a JWT access token + opaque refresh token model. Access tokens are short-lived; refresh tokens are rotated on every use.

Authentication Flow

Create Anonymous User

Start a new session without any credentials. Returns a fresh user with tokens.
curl -X POST https://api.unisave.io/v1/auth/anonymous
Response 200
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "opaque-refresh-token-string",
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "isAnonymous": true,
    "displayName": null,
    "email": null,
    "photoUrl": null,
    "isPro": false
  }
}
OAuth endpoints serve dual purpose based on whether an access token is provided:
  • Without tokenSign-in: Creates or retrieves the user associated with the OAuth identity
  • With token + link=trueLink: Attaches the OAuth identity to the current user

Google OAuth

# Sign-in (no token)
curl -X POST https://api.unisave.io/v1/auth/oauth/google \
  -H "Content-Type: application/json" \
  -d '{"idToken": "google-id-token-from-client"}'

# Link to current user
curl -X POST https://api.unisave.io/v1/auth/oauth/google \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"idToken": "google-id-token", "link": true}'

Apple OAuth

# Sign-in
curl -X POST https://api.unisave.io/v1/auth/oauth/apple \
  -H "Content-Type: application/json" \
  -d '{"identityToken": "apple-identity-token", "authorizationCode": "code"}'

# Link to current user
curl -X POST https://api.unisave.io/v1/auth/oauth/apple \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"identityToken": "apple-identity-token", "authorizationCode": "code", "link": true}'
Response 200 — same shape as anonymous auth.

Identity Conflict (Merge)

If the OAuth identity already belongs to another user and you’re linking:
  1. The backend signs into the existing user (owner of the identity)
  2. Bookmarks from the anonymous account are merged into the target account (best-effort)
  3. The anonymous account is cleaned up
If merge isn’t possible, the API returns 409 identity-already-in-use.

Refresh Tokens

Access tokens expire. Use the refresh endpoint to get new ones. Refresh tokens are single-use — each call returns a new pair.
curl -X POST https://api.unisave.io/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "current-refresh-token"}'
Response 200
{
  "accessToken": "new-jwt-access-token",
  "refreshToken": "new-opaque-refresh-token",
  "user": { ... }
}
Refresh tokens rotate on every use. If you use an old refresh token, it’s rejected. Store the latest refresh token securely (Keychain on iOS, chrome.storage.local in the extension).

Logout

Revokes the refresh token server-side. The access token remains valid until expiry but new tokens cannot be minted.
curl -X POST https://api.unisave.io/v1/auth/logout \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "current-refresh-token"}'
Response 204 No Content

User Profile

Get the current user’s profile and subscription status.
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
  https://api.unisave.io/v1/me
Response 200
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "isAnonymous": false,
  "displayName": "Justin",
  "email": "justin@example.com",
  "photoUrl": "https://...",
  "isPro": true
}

Delete Account

Permanently deletes the user account and all associated data (bookmarks, collections, enrichment jobs, subscriptions). This action is irreversible.
curl -X DELETE -H "Authorization: Bearer $ACCESS_TOKEN" \
  https://api.unisave.io/v1/account
Response 204 No Content