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.
Put this code in any record in your atproto repo. A short Bluesky or Blacksky post is the easiest path β but a profile bio, a custom collection, any record that hits the firehose works just as well.
The code wasn't seen on the firehose within 300 seconds. Try again β codes are cheap.
What it's for
A primitive for "does this person actually own this atmosphere account (atproto handle)" β without making them sign in.
- Sign-in-with-Atmosphere flows that don't want to deal with OAuth, PKCE, DPoP, refresh tokens.
- Identity binding β connect an external account (Discord, email, your DB row) to an atproto identity once, persist the DID.
- Anti-spam onboarding β confirm a real Atmosphere account took the action, not a script with a stolen JWT.
- Account recovery β re-bind when an OAuth refresh fails or session expires.
- Webrings, allowlists, community gating β anywhere you want proof of handle ownership in the Atmosphere without storing passwords.
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.
| Action | XRPC | REST alias |
|---|---|---|
| Create | POST /xrpc/at.verifire.createChallenge | POST /verify |
| Get | GET /xrpc/at.verifire.getChallenge | GET /verify/:challengeId |
| Health | GET /xrpc/_health | GET /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:
| Field | Type | Default | Notes |
|---|---|---|---|
| webhookUrl | string (uri) | β | Public http(s) URL we POST when verified. No retries, no signing. |
| codeLength | integer | 8 | Length of the random code, 8 β 32. Default ~42 bits entropy; bump up for stricter "harder to leak / harder to brute-force" needs. |
| ttlSeconds | integer | 300 | How long the challenge stays valid, 30 β 86400 (24h). |
| requirePrefix | string | β | 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" }.
| HTTP | error | When |
|---|---|---|
| 400 | InvalidRequest | Missing or invalid challengeId, or one of codeLength / ttlSeconds / requirePrefix failed validation. |
| 400 | InvalidWebhookUrl | Not http(s), or resolves to a private IP / loopback / link-local / CGNAT. |
| 404 | ChallengeNotFound | Unknown id, or already swept after expiry. |
| 429 | β | Rate limit exceeded. 10/min for create, 60/min for get, per IP. |
| 503 | AtCapacity | Pending-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.