Recover a domain Persona
curl --request POST \
--url https://api.example.com/personas/{persona}/recovery/challengesimport requests
url = "https://api.example.com/personas/{persona}/recovery/challenges"
response = requests.post(url)
print(response.text)const options = {method: 'POST'};
fetch('https://api.example.com/personas/{persona}/recovery/challenges', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/personas/{persona}/recovery/challenges",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.example.com/personas/{persona}/recovery/challenges"
req, _ := http.NewRequest("POST", url, nil)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.example.com/personas/{persona}/recovery/challenges")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.example.com/personas/{persona}/recovery/challenges")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
response = http.request(request)
puts response.read_bodyPersonas
Recover a domain Persona
Replace a lost or previous operator DID using domain proofs and an elapsed recovery window
Recover a domain Persona
curl --request POST \
--url https://api.example.com/personas/{persona}/recovery/challengesimport requests
url = "https://api.example.com/personas/{persona}/recovery/challenges"
response = requests.post(url)
print(response.text)const options = {method: 'POST'};
fetch('https://api.example.com/personas/{persona}/recovery/challenges', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/personas/{persona}/recovery/challenges",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.example.com/personas/{persona}/recovery/challenges"
req, _ := http.NewRequest("POST", url, nil)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.example.com/personas/{persona}/recovery/challenges")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.example.com/personas/{persona}/recovery/challenges")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
response = http.request(request)
puts response.read_bodyRecovery lets the current domain administrator replace a Persona operator when the old key is lost or the old operator refuses to transfer it. It works for an existing domain Persona, including an expired or revoked one. Single-label Personas cannot use this procedure.
Private keys remain local. The Directory verifies DNS and email, prepares the canonical SCALE payload, and submits the locally signed request. The chain independently checks attestor admission, proof signatures, replacement-DID signatures, proof expiry, and recovery timing.
The seven-day window begins when the first verified proof is included in finalized chain state. A controller cancellation records an objection and cannot restart the window or prevent an eligible recovery. Time alone never establishes authority.
DNS and email proof challenges expire after 48 hours. Refresh proof near day seven for a recovery using one proof. A pending chain request expires after 14 days; refreshing proof does not restart its original window. Pending requests are bounded per domain, and expired chain entries are pruned when a new request is submitted. The Directory periodically purges expired local challenges.
DNS and email both depend on domain administration. Changing DNS MX records can redirect recovery email, so these are not independent factors against compromise of DNS administration.
Send that body to:
The response includes a
The address cannot be overridden. The response reports delivery acceptance and expiry; it never contains the confirmation token. Confirm the token supplied by the email:
Tokens are single-use and expire with the 48-hour challenge. Failed confirmation attempts are limited. Email delivery can be unavailable while DNS-only recovery remains available.
The Directory derives verified proof factors; clients cannot claim unverified factors.
A finalized pending record includes its replacement DID, controllers, policy choice, start and expiration times, proof expirations, and
Publish or confirm the new value, prepare action
Recovery requires runtime spec 124 or later and the new Directory release. These changes are staged and have not yet been deployed to alpha. Email proof also requires the Directory operator to configure delivery to the fixed recovery mailbox.
Recovery conditions
| Evidence | Completion |
|---|---|
| Fresh DNS proof and fresh email proof | Immediate, including after controller cancellation |
| Fresh DNS or email proof, plus seven elapsed days | Permitted, including after cancellation |
| Neither proof | Never |
1. Create the challenge
The replacement DID must already be active and publish its Ed25519 signing keys. Request initial controllers and choose what happens to the policy:{
"operator_did": "did:openpayload:2222222222222222222222",
"controller_keys": ["did:openpayload:2222222222222222222222#root"],
"policy": "preserve"
}
POST /personas/{persona}/recovery/challenges
recovery_id as 0x followed by 64 hexadecimal characters, the exact DNS record_name and record_value, expires_at in Unix milliseconds, and the fixed recovery email address. It may include the issuing directory_url; keep using that Directory for challenge verification and submission.
Publish the exact TXT value at the returned record name. Up to eight distinct Ed25519 controller keys are permitted; the threshold remains 1.
Preserve or clear the policy
policy defaults to preserve. Recovery retains graph rules, references, policy IDs, and revisions, and assigns their operator metadata to the replacement DID. References to the old DID inside graphs are not rewritten. The preserved policy is the policy stored when recovery executes.
policy: "clear" atomically removes the Persona’s V2 and V3 policies while replacing the operator. Persona delivery constraints remain unchanged in both modes. The choice and new controllers are bound to the signed recovery request.
2. Optionally verify email
Request delivery only toopenpayload-recovery@{persona}:
POST /personas/{persona}/recovery/{recovery_id}/email
POST /personas/{persona}/recovery/{recovery_id}/email/confirm
{"token":"<token-delivered-by-email>"}
3. Prepare, sign, and submit
Send an unsigned intent:{
"action": "start_recovery",
"operator_did": "did:openpayload:2222222222222222222222",
"signer_key_id": "did:openpayload:2222222222222222222222#root",
"verification_days": 365,
"use_dns": true
}
POST /personas/{persona}/recovery/{recovery_id}/prepare
use_dns: false permits an email-only request. verification_days accepts 1–365 and defaults to 365; verification_expires_at can supply an explicit absolute expiry instead. The Directory fills in the current operator nonce and a short authorization expiration when omitted.
The response contains canonical_payload and the complete normalized request. Decode the hexadecimal payload and sign its exact bytes locally. Preserve the returned request fields, add canonical_payload and a Base64 or hexadecimal signature, then submit:
PUT /personas/{persona}/recovery/{recovery_id}
202 Accepted returns tx_id; it means submission for processing. Wait for finalized recovery state. Invalid signatures, unverified factors, stale nonces, modified canonical payloads, or expired challenges are rejected before submission.
4. Check, refresh, or finalize
GET /personas/{persona}/recovery/{recovery_id}
cancelled. The response supplies ready_at, eligible, and proof_refresh_required. A local challenge without a chain request reports awaiting_proof.
To refresh an existing pending request, create a challenge with the same replacement DID, controllers, and policy choice:
POST /personas/{persona}/recovery/{recovery_id}/challenges
prove_recovery, sign, and submit through the same prepare and submission routes. This refreshes proof without changing started_at.
Prepare action finalize_recovery with the replacement DID and a recovery-controller signing key when eligible is true. Sign and submit it through the same routes. The finalized Persona becomes active under the new operator, and pending recovery requests for that Persona are removed.
Controller cancellation
A current Persona controller can prepare actioncancel_recovery, using the current operator DID and its authorized signing key. Sign and submit through the same routes. Cancellation records an objection; an eligible recovery follows the conditions above.
Recovery replaces Persona ownership. It does not restore a DID private key or transfer separately registered Applications.
CLI
The OpenPayloadopenpayload_register.py tool supports registration, renewal, DNS hooks for scheduled jobs, saved progress, and recovery. Use persona recover --policy preserve or --policy clear; the default is preserve. See the tool instructions.
Straight to the point
Create: POST /personas/{persona}/recovery/challenges
Refresh challenge: POST /personas/{persona}/recovery/{recovery_id}/challenges
Request email: POST /personas/{persona}/recovery/{recovery_id}/email
Confirm email: POST /personas/{persona}/recovery/{recovery_id}/email/confirm
Prepare: POST /personas/{persona}/recovery/{recovery_id}/prepare
Submit: PUT /personas/{persona}/recovery/{recovery_id}
Status: GET /personas/{persona}/recovery/{recovery_id}

