Participant authentication
No password. The same pair of endpoints serves someone who has never used the app and someone who already has an account — the API decides internally whether to create or just authenticate.
| Endpoint | What it does |
|---|---|
POST /v1/auth/wallet/otp/request | Sends the code by SMS. Requires a Cloudflare Turnstile challengeToken. The response is always neutral — it doesn't reveal whether the phone already had an account. |
POST /v1/auth/wallet/otp/verify | Confirms the code. Creates the identity if the phone is new, or authenticates the existing one. Returns accessToken/refreshToken/profile in the body and sets the wallet_user_* cookies. |
POST /v1/auth/wallet/refresh | Rotates the refresh token (body, X-Refresh-Token header, or cookie). Rejects a refresh token from another profile. |
POST /v1/auth/wallet/logout | Revokes the current session and clears the three wallet_user_* cookies. |
Examples
The examples use http://127.0.0.1:8787 (the local wallet-edge). In
production, swap in your environment's public Edge URL.
- curl
- JavaScript
- Python
curl -X POST http://127.0.0.1:8787/v1/auth/wallet/otp/request \
-H "Content-Type: application/json" \
-d '{
"phone": "+5511999999999",
"challengeToken": "<Turnstile token>"
}'
const response = await fetch("http://127.0.0.1:8787/v1/auth/wallet/otp/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
phone: "+5511999999999",
challengeToken: "<Turnstile token>",
}),
});
import requests
requests.post(
"http://127.0.0.1:8787/v1/auth/wallet/otp/request",
json={
"phone": "+5511999999999",
"challengeToken": "<Turnstile token>",
},
)
Once the SMS arrives, confirm the code:
- curl
- JavaScript
- Python
curl -X POST http://127.0.0.1:8787/v1/auth/wallet/otp/verify \
-H "Content-Type: application/json" \
-d '{
"phone": "+5511999999999",
"token": "123456"
}'
const response = await fetch("http://127.0.0.1:8787/v1/auth/wallet/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: "+5511999999999", token: "123456" }),
});
const session = await response.json();
// session.accessToken, session.refreshToken, session.profile
import requests
response = requests.post(
"http://127.0.0.1:8787/v1/auth/wallet/otp/verify",
json={"phone": "+5511999999999", "token": "123456"},
)
session = response.json()
# session["accessToken"], session["refreshToken"], session["profile"]
Phone must be in E.164
phone is always in the format +5511999999999 (with +, country code, no
spaces or dashes). Supabase — which issues the OTP under the hood — stores
the phone without the + internally; this is already handled in the
backend, the client just needs to always send it with +.
profileComplete: false — what to do
When verify responds with profile.profileComplete === false, the phone
is new and the profile doesn't have a name yet. In that case, collect the
name and call:
PATCH /v1/auth/wallet/me
Authorization: Bearer <accessToken>
Content-Type: application/json
{ "displayName": "Maria Silva" }
See Profile for the full contract of that endpoint.
Rate limiting
otp/request is limited both by IP and by a hash of the normalized
phone number — a single number can't get an endless stream of SMS even by
switching IPs, and one IP can't flood different numbers without hitting the
per-IP limit. otp/verify also has its own limit, to make brute-forcing the
6-digit code harder.