Verifire

Verification through Firehose

Verify control of an atproto account from your app.

No OAuth, no app passwords.

Issue a one-time code. The user posts it from any atproto app, or writes it into any record in their repo (a Bluesky post, a Blacksky post, a custom record). Verifire spots it on the firehose and returns the DID, handle, and record URI.

Verifire proves moment-in-time control of an account, nothing more. Best for low-to-medium-stakes flows, not a substitute for OAuth.

Reach for it when OAuth or app passwords aren't an option and you just need to know which atproto account you're talking to.

Verify a handle

Click for a one-time code, then post it on Bluesky, Blacksky, or any atproto record from your account.


Good for / not for

Good forNot for
Event check-insFull login replacement
Bot / admin verificationPayments, sensitive auth
Account linkingLong-term identity guarantees
Demos, hackathonsAnywhere OAuth fits and you can use it

API

Two HTTP calls: create a challenge, then poll for the result. Or pass a webhookUrl and we'll POST you when it lands.

Smallest version:

# create a challenge, get a code
curl -X POST https://verifire.at/xrpc/at.verifire.createChallenge
→ { "challengeId": "chl-…", "code": "k3m9xq7p" }

# user posts the code anywhere in their repo, then poll
curl https://verifire.at/xrpc/at.verifire.getChallenge?challengeId=chl-…
→ { "status": "verified", "did": "did:plc:…", "handle": "alice.bsky.social" }
POST/xrpc/at.verifire.createChallenge
GET/xrpc/at.verifire.getChallenge?challengeId=…
GET/xrpc/_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-",
  "codeAlphabet":  "numeric",
  "expectedDid":   "did:plc:abc...",
  "collection":    "app.bsky.feed.post"
}
FieldDefaultNotes
webhookUrlPublic http(s) URL we POST when verified. E.g. https://your-app.com/verifire-webhook. Body shape: { challengeId, did, handle, matchedAt, recordUri }. Fire-and-forget, no retries, no signing.
codeLength8Length of the random code. Range 8 – 32. Default 8 → ~42 bits entropy (e.g. k3m9xq7p); 16 for higher security (e.g. k3m9xq7pa1b2c3d4).
ttlSeconds300Validity window in seconds. Range 30 – 86400 (24 h). 60–120 for tight in-app flows; 600+ when the user needs time to switch apps and post.
requirePrefixIf set, the user must post prefix + code. E.g. with requirePrefix: "acme-" and code k3m9xq7p, the user posts acme-k3m9xq7p. Eliminates false-positive matches and signals intent. 1–32 chars, no control chars.
codeAlphabetalphanumericDefault alphanumeric (a-z + 0-9). Override to numeric for digit-only codes (e.g. 4853109267), easier to read over voice or type on a phone keypad. Pair with longer codeLength to compensate for lower entropy per character.
expectedDidRestrict the challenge to a specific account. E.g. did:plc:7gm5ejhut7kia2kzglqfew5b. Only that DID's posts will match; posts from any other account are silently ignored. Useful when you already know which atproto account you're verifying (login flow, account-recovery flow, bot operator config).
collectionRestrict matching to a single collection (NSID). E.g. app.bsky.feed.post to only accept Bluesky posts, or your own record type like events.atproto.checkin. Records in any other collection are ignored. Unset (default) = the code matches in a record of any collection.

Response (200):

{
  "challengeId": "chl-…",
  "code":        "k3m9xq7p",
  "expiresAt":   "2026-04-26T03:05:00.000Z",
  "ttlSeconds":  300,
  "instruction": "Post the code k3m9xq7p anywhere in your atproto repo …"
}

Get a challenge's status

GET /xrpc/at.verifire.getChallenge?challengeId=chl-…

Pending:

{ "status": "pending", "expiresAt": "…" }

Verified:

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

Expired:

{ "status": "expired", "expiresAt": "…" }

Webhook payload

Sent once on a successful match if webhookUrl was set. No retries, no signing.

{
  "challengeId": "chl-…",
  "did":         "did:plc:…",
  "handle":      "alice.bsky.social",
  "matchedAt":   "2026-04-26T03:01:42.123Z",
  "recordUri":   "at://did:plc:…/app.bsky.feed.post/3kj7yqz2"
}

Errors

XRPC shape: { "error": "Code", "message": "…" }

HTTPerrorWhen
400InvalidRequestBad challengeId, or option failed validation.
400InvalidWebhookUrlNot http(s) or resolves to a private IP.
404ChallengeNotFoundUnknown id, or already swept.
429Rate limit: 10/min create, 60/min get, per IP.
503AtCapacityPending cap reached. Retry shortly.

For integrators

Verifire reports the DID that posted your code, and when. That's a moment-in-time proof of control, nothing more. Three rules to use it safely:

  1. Create challenges from your server. Your backend owns the challengeId and ties it to the session you're verifying. Create it in the browser and that mapping is the user's to forge.
  2. Check the returned did. On verified, only proceed if it's the account you expected. Or set expectedDid up front and Verifire enforces it, ignoring everyone else.
  3. The code is a bearer token. Whoever posts it verifies as their own DID, so the real risk isn't a stranger claiming it. It's relaying your code to the genuine owner and having them post it for you. A short ttlSeconds plus expectedDid close that window.