Authentication-Based Recovery API (Email / SMS)
A Safe guardian service that uses email and phone verification to facilitate account recovery. It can be used as a default recovery method or combined with other guardians (such as hardware wallets or trusted contacts) to create a customized recovery threshold.
To get started, request access here.
Authentication
All API requests require a Bearer token in the Authorization header:
curl -X POST \
https://yourcompany.recovery.candide.dev/auth/register \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_BEARER_TOKEN' \
-d '{...}'
/auth (Registration)
- /auth/register
- POST: Submit a registration request
- /auth/submit
- POST: Submits the receive OTP
- /auth/registrations
- GET: Fetch active registration
- /auth/delete
- POST: Delete a registration
/auth/signature (Recovery)
- /auth/signature/request
- POST: Request to recover an account
- /auth/signature/submit
- POST: Confirm recovery with OTP challenge
Auth Registration
Email / SMS
Submit a registration request with the target smart account to protect using the choice of channel (email or SMS).
The user will then receive an OTP code to later submit the challenge in /auth/submit.
POST /auth/register
- Example Request
- Example Response
- Request Body
- Response Body
curl -X POST \
https://yourcompany.recovery.candide.dev/auth/register \
-H 'Content-Type: application/json' \
-d '{
"account":"0x...",
"chainId": 1,
"channel":"email",
"target":"user@example.com",
"message": "siwe(chainId, statement(channel, target))",
"signature": "sign(message)"
}'
{
"challengeId":"unique-challenge-id",
}
| key | type | description |
|---|---|---|
account | string | The smart account address requesting registration |
chainId | number | The chain ID where the account resides |
channel | string | Authentication channel: 'email' or 'sms' |
target | string | The email or phone number for authentication |
message | string | SIWE (EIP-4361) message signed by the account |
signature | string | Signature proving the request is initiated from the account |
| key | type | description |
|---|---|---|
challengeId | string | Unique challenge ID for the registration, used in /auth/submit |
account: The smart account address requesting registration.chainId: The chain id in which your account resides (for multi-chain wallets, users will need to register per chain)channel: Either"email"or"sms"(defines the authentication type).target: The email or phone number for authentication.message: SIWE (EIP-4361) message statement. Statement:
I authorize Safe Recovery Service to sign a recovery request for my account after I authenticate using {{target}} via {{channel}}
signature: signature proving the request is initiated from the account
See example guide how to construct the message and signature using Sign-In with Ethereum (SIWE).
Submit Confirmation Using OTP
Submit the received OTP code to confirm ownership of the target channel.
POST /auth/submit
- Example Request
- Example Response
- Request Body
- Response Body
curl -X POST \
https://yourcompany.recovery.candide.dev/auth/submit \
-H 'Content-Type: application/json' \
-d '{
"challengeId":"unique-challenge-id",
"challenge":"123456"
}'
{
"registrationId": "unique-registration-id",
"guardianAddress": "0x...",
}
| key | type | description |
|---|---|---|
challengeId | string | The unique challenge ID received from /auth/register |
challenge | string | The OTP code received via email/SMS |
| key | type | description |
|---|---|---|
registrationId | string | Unique registration ID for the authenticated channel |
guardianAddress | string | The guardian address added to the Safe account |
Get active registration
Fetch the registration of the protected smart account
GET /auth/registrations
- Example Request
- Example Response
- Query Parameters
- Response Body
curl -G "https://yourcompany.recovery.candide.dev/auth/registrations" \
--data-urlencode "account=0x...",
--data-urlencode "chainId=0x1",
--data-urlencode "message=siwe(chainId, statement)",
--data-urlencode "signature=sign(message)"
{
"registrations": [
{
"id": "unique-registration-id",
"channel": "email",
"target": "user@example.com",
}
]
}
| key | type | description |
|---|---|---|
account | string | The Safe account address |
chainId | number | The chain ID where the account resides |
message | string | SIWE message signed by the account |
signature | string | Signature proving the request |
| key | type | description |
|---|---|---|
registrations | array | List of active registrations |
See example guide how to construct the message and signature using Sign in With Ethereum (SIWE)
Delete
Deletes a registration
POST /auth/delete
- Example Request
- Example Response
- Request Body
- Response Body
curl -X POST \
https://yourcompany.recovery.candide.dev/auth/delete \
-H 'Content-Type: application/json' \
-d '{
"registrationId":"unique-registration-id",
"message": "siwe(chainId, statement(registrationId))",
"signature": "sign(registrationId, timestamp)"
}'
{
"success": "true"
}
| key | type | description |
|---|---|---|
registrationId | string | The registration ID to delete |
message | string | SIWE message signed by the account |
signature | string | Signature proving the request |
| key | type | description |
|---|---|---|
success | boolean | True if deletion was successful |
See example guide how to construct the message and signature using Sign in With Ethereum (SIWE)
Auth Recovery
Request to recover an account
Request a signature from the service to recover an account given the new owners and threshold
POST /auth/signature/request
- Example Request
- Example Response
- Request Body
- Response Body
curl -X POST \
https://yourcompany.recovery.candide.dev/auth/signature/request \
-H 'Content-Type: application/json' \
-d '{
"account":"0x...",
"newOwners": ["0x...", "0x..."],
"newThreshold": 2,
"chainId": 1
}'
{
"requestId":"unique-signature-request-id",
"requiredVerifications": 1,
"auths": [
{
"challengeId": "unique-challenge-id",
"channel": "email",
"target": "us**@exa****.com"
}
]
}
| key | type | description |
|---|---|---|
account | string | The smart account address to be recovered |
newOwners | string[] | The new owners for the Safe account |
newThreshold | number | The new threshold for the Safe account |
chainId | number | Chain ID for the recovery request |
| key | type | description |
|---|---|---|
requestId | string | Unique signature request ID |
requiredVerifications | number | Minimum number of OTP challenges required |
auths | array | List of authentication methods to verify |
Confirm recovery with OTP challenge
Request to submits the signature with the provided OTP code challenge and id
POST /auth/signature/submit
- Example Request
- Example Response
- Request Body
- Response Body
curl -X POST \
https://yourcompany.recovery.candide.dev/auth/signature/submit \
-H 'Content-Type: application/json' \
-d '{
"requestId": "unique-signature-request-id",
"challengeId": "unique-challenge-id",
"challenge": "123456"
}'
{
"success": true,
"signer": "0x...",
"signature": "0x..."
}
| key | type | description |
|---|---|---|
requestId | string | The unique ID from /auth/signature/request |
challengeId | string | The challenge ID specific to the authentication method |
challenge | string | The OTP code received via email/SMS |
| key | type | description |
|---|---|---|
success | boolean | True if verification was successful |
signer | string | Guardian address (available only if sufficient verifications collected) |
signature | string | Recovery signature (available only if sufficient verifications collected) |
How to Sign Messages (SIWE EIP-4361)
- SIWE
- safe-utils
- example of what users will see
import { SiweMessage } from "siwe";
import { hexlify, randomBytes } from "ethers";
import { personalSign, getMessageHashForSafe } from "./safe-utils"
function generateSIWEMessageSignaturePair(safeAccountAddress: string, statement: string, chainId: string): [string, string] {
const siweMessage = new SiweMessage({
version: "1",
address: ethers.getAddress(accountAddress),
domain: "service://safe-recovery-safeAccountAddress",
uri: "service://safe-recovery-service",
statement,
chainId: Number(chainId),
nonce: hexlify(randomBytes(24)),
});
const message = siweMessage.prepareMessage();
const signature = personalSign(safeAccountAddress, message, BigInt(chainId));
return [message, signature];
}
import { hashMessage, Wallet } from "ethers";
export function getMessageHashForSafe(safeAccountAddress: string, message: string, chainId: BigInt) {
const SAFE_MSG_TYPEHASH = "0x60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca";
const DOMAIN_SEPARATOR_TYPEHASH = "0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218";
const domainSeparator = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(
["bytes32", "uint256", "address"],
[DOMAIN_SEPARATOR_TYPEHASH, chainId, safeAccountAddress]
));
const encodedMessage = ethers.AbiCoder.defaultAbiCoder().encode(
["bytes32", "bytes32"],
[SAFE_MSG_TYPEHASH, ethers.keccak256(message)]
);
const messageHash = ethers.keccak256(ethers.solidityPacked(
["bytes1", "bytes1", "bytes32", "bytes32",],
[Uint8Array.from([0x19]), Uint8Array.from([0x01]), domainSeparator, ethers.keccak256(encodedMessage)]
));
return messageHash;
}
export function personalSign(safeAccountAddress: string, message: string, chainId: BigInt){
const hash = hashMessage(message);
const safeMessageHash = await getMessageHashForSafe(safeAccountAddress, message, chainId);
const signer = new Wallet(process.env.privateKey)
return signer.signingKey.sign(messageHash).serialized;
}
service://safe-recovery-service wants you to sign in with your Ethereum account:
0x13D6D891307758afc45EE42C90bFE7636C32088b
I request to retrieve all Social Recovery Module alert subscriptions linked to my account
URI: service://safe-recovery-service
Version: 1
Chain ID: 11155420
Nonce: 0x95e25544f0f05b90c12b92d5a0d29666b99c77a47b00e854
Issued At: 2025-03-13T15:47:08.746Z
Error Handling
The API uses standard HTTP status codes to indicate the success or failure of a request. Error responses include a JSON object with the following structure:
{
"error": {
"code": 404,
"message": "Registration not found"
}
}
HTTP Status Codes
| Code | Description |
|---|---|
| 200 | Success |
| 400 | Bad Request - Invalid parameters or missing required fields |
| 401 | Unauthorized - Invalid or missing Bearer token |
| 404 | Not Found - Resource not found |
| 429 | Too Many Requests - Rate limit exceeded |
| 500 | Internal Server Error - Something went wrong on the server |
Common Error Messages
| Message | Description |
|---|---|
| Registration not found | The requested registration ID does not exist |
| Invalid signature | The SIWE signature verification failed |
| Challenge expired | The OTP challenge has expired |
| Invalid challenge | The OTP code provided is incorrect |
| Rate limit exceeded | Too many requests, please try again later |