Verifire πŸ”₯

Verify ownership of an atmosphere account (atproto handle) from your app β€” no login, no OAuth, no PDS calls.

Issue a one-time code. The user creates any record containing it β€” a Bluesky post, a Blacksky post, a profile bio edit, anything in their repo. Verifire spots it on the firehose and hands you back the DID and handle. The whole loop takes about a second.

Click below for a one-time code. Put it in any record in your atproto repo β€” Bluesky post, Blacksky post, profile bio, custom collection β€” and we'll find you under a second.


What it's for

A primitive for "does this person actually own this atmosphere account (atproto handle)" β€” without making them sign in.


API

Two HTTP calls β€” create a challenge, poll until verified or expired. Each endpoint has an XRPC path (canonical) and a friendlier REST alias; both serve the same handler.

ActionXRPCREST alias
CreatePOST /xrpc/at.verifire.createChallengePOST /verify
GetGET /xrpc/at.verifire.getChallengeGET /verify/:challengeId
HealthGET /xrpc/_healthGET /health

Create a challenge

POST /xrpc/at.verifire.createChallenge
Content-Type: application/json

// All fields optional.
{
  "webhookUrl":    "https://your-app.example/verifire-callback",
  "codeLength":    16,
  "ttlSeconds":    600,
  "requirePrefix": "verifire-"
}

Optional input parameters:

FieldTypeDefaultNotes
webhookUrlstring (uri)β€”Public http(s) URL we POST when verified. No retries, no signing.
codeLengthinteger8Length of the random code, 8 – 32. Default ~42 bits entropy; bump up for stricter "harder to leak / harder to brute-force" needs.
ttlSecondsinteger300How long the challenge stays valid, 30 – 86400 (24h).
requirePrefixstringβ€”If set, the user must post requirePrefix + code rather than just the code. Eliminates false-positive matches and signals intent. Max 32 chars, no control chars.

Response (200 OK):

{
  "challengeId":   "chl-9f8a0c4e-2b1d-4e6f-9a3b-1c2d3e4f5a6b",
  "code":          "k3m9xq7p",
  "requirePrefix": "verifire-",
  "expiresAt":     "2026-04-26T03:05:00.000Z",
  "ttlSeconds":    300,
  "instruction":   "Post the text \"verifire-k3m9xq7p\" anywhere in your atproto repo (e.g. a Bluesky or Blacksky post) within 300 seconds."
}

requirePrefix is echoed only when set. The instruction string is human-readable and reflects whichever options you passed.

Get a challenge's status

GET /xrpc/at.verifire.getChallenge?challengeId=chl-9f8a0c4e-2b1d-4e6f-9a3b-1c2d3e4f5a6b

While waiting (poll every couple seconds):

{
  "status": "pending",
  "expiresAt": "2026-04-26T03:05:00.000Z"
}

When the firehose matches:

{
  "status": "verified",
  "did": "did:plc:abcdefghij1234567890",
  "handle": "alice.bsky.social",
  "recordUri": "at://did:plc:abcdefghij1234567890/app.bsky.feed.post/3kj7yqz2",
  "matchedAt": "2026-04-26T03:01:42.123Z",
  "expiresAt": "2026-04-26T03:05:00.000Z"
}

If the TTL passes without a match:

{
  "status": "expired",
  "expiresAt": "2026-04-26T03:05:00.000Z"
}

Webhook payload

If webhookUrl was set on creation, on a successful match Verifire sends one fire-and-forget POST. No retries, no signing β€” keep your endpoint idempotent. The same data is also returned by getChallenge, so the webhook is purely an optimization to skip polling.

POST your-webhook-url
Content-Type: application/json

{
  "challengeId": "chl-9f8a0c4e-2b1d-4e6f-9a3b-1c2d3e4f5a6b",
  "did": "did:plc:abcdefghij1234567890",
  "handle": "alice.bsky.social",
  "matchedAt": "2026-04-26T03:01:42.123Z",
  "recordUri": "at://did:plc:abcdefghij1234567890/app.bsky.feed.post/3kj7yqz2"
}

Errors

All errors use the XRPC standard shape: { "error": "Code", "message": "human readable" }.

HTTPerrorWhen
400InvalidRequestMissing or invalid challengeId, or one of codeLength / ttlSeconds / requirePrefix failed validation.
400InvalidWebhookUrlNot http(s), or resolves to a private IP / loopback / link-local / CGNAT.
404ChallengeNotFoundUnknown id, or already swept after expiry.
429β€”Rate limit exceeded. 10/min for create, 60/min for get, per IP.
503AtCapacityPending-challenge cap reached. Retry shortly.

Examples

curl β€” defaults

# 1. Create a challenge with default settings
RES=$(curl -s -X POST https://verifire.at/xrpc/at.verifire.createChallenge \
  -H 'content-type: application/json' -d '{}')
CODE=$(echo "$RES" | jq -r .code)
ID=$(echo "$RES"   | jq -r .challengeId)
echo "Tell your user to post: $CODE"

# 2. Poll until verified or expired
while :; do
  R=$(curl -s "https://verifire.at/xrpc/at.verifire.getChallenge?challengeId=$ID")
  case "$(echo "$R" | jq -r .status)" in
    verified) echo "$R" | jq; break ;;
    expired)  echo "Expired"; break ;;
    *)        sleep 2 ;;
  esac
done

curl β€” stricter

# 16-char code, 60s window, must be posted with a "verifire-" prefix
curl -s -X POST https://verifire.at/xrpc/at.verifire.createChallenge \
  -H 'content-type: application/json' \
  -d '{
    "codeLength": 16,
    "ttlSeconds": 60,
    "requirePrefix": "verifire-"
  }'
# instruction in response β†’ "Post the text \"verifire-abcdef0123456789\" …"

JavaScript / TypeScript

const base = 'https://verifire.at';

// 1. Create
const c = await fetch(base + '/xrpc/at.verifire.createChallenge', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({}),
}).then(r => r.json());
console.log('Tell user to post: ' + c.code);

// 2. Poll until verified or expired
while (true) {
  const r = await fetch(
    base + '/xrpc/at.verifire.getChallenge?challengeId=' + c.challengeId
  ).then(r => r.json());
  if (r.status === 'verified') { console.log(r); break; }
  if (r.status === 'expired')  { throw new Error('expired'); }
  await new Promise(res => setTimeout(res, 2000));
}

Lexicons

XRPC schema definitions for at.verifire.createChallenge and at.verifire.getChallenge live in lexicons/at/verifire/ in the repo. Drop-in compatible with @atproto/api agents and any XRPC tooling.