Jump to a section

Assentry developer documentation

Accept AI agents on your website

Assentry lets your site recognise an AI agent acting on behalf of a real, consenting person, and pull only the data that person agreed to share, without the agent ever touching a password.

Overview & concepts

Four things are involved in every request an agent makes to your site:

ConceptWhat it is
PersonSomeone with an Assentry account. They own their identity and decide which agents can act for them.
AgentAn AI assistant (Claude, ChatGPT, …) the person connected to Assentry. It never holds the person's passwords.
GrantThe person's authorisation for one agent: which profile fields it may disclose, and until when. The person can revoke it at any time.
CredentialA signed token issued for your site's verified domain only: Assentry refuses it anywhere else. Browser agents get it by approving the sign-in code on your page; others fetch it and send Authorization: Bearer <credential>. It lasts as long as the person's grant allows and contains no personal data. Signed with Assentry's published keys (https://api.assentry.io/.well-known/jwks.json), but always check it with verify, which also catches withdrawn permission.

What your site does

When a request arrives with a credential, your server asks Assentry whether it's valid (verify), every time, never trusting the token on its own. If it is, you know which person the agent represents, as an ID specific to you. If you need details like their name or email, you ask Assentry for them separately (getProfile), and you only get details you declared in the console and the person allowed.

What Assentry does not do

  • It never reads, fetches or touches your site's content. The agent does its own browsing.
  • It never sees or stores passwords for your site.
  • It doesn't perform the agent's task, or decide what an authorised agent may do on your site. Your own code does.
There is no per-action permission scope today: a valid credential means “this agent is acting for this person, with their consent.” If some routes should only be reachable for certain people or actions, enforce that in your own application logic, exactly as you would for a logged-in human.

Every verification and profile request is recorded in the person's own Assentry audit log.

Quickstart

  1. Create a developer account.
  2. In the console, create an app with your site's exact domain (e.g. shop.example.com) and copy its API key. It's shown once: store it as a secret on your server.
  3. Under Required data, tick what your site needs and mark each detail required or optional.
  4. Install the SDK:
Terminal
npm install @assentry/sdk

Set your key as an environment variable on your server (never in browser code):

.env
ASSENTRY_API_KEY=assentry_your_key_here

Make the site discoverable, and protect a route:

server.ts
import express from "express";
import { Assentry, assentryDiscovery, requireGrant, type AssentryRequest } from "@assentry/sdk";

const assentry = new Assentry({
  apiKey: process.env.ASSENTRY_API_KEY!,
});

const app = express();
app.use(express.json());

// Lets agents find and sign in to your site (see "Agent discovery"). Passing
// your client turns on browser sign-in: pages show a one-time sign-in code
// the agent approves through Assentry, and serves your domain-verification proof.
app.use(assentryDiscovery({ partnerName: "Acme", assentry }));

app.get(
  "/account",
  requireGrant(assentry, {
    discover: { path: "/account", method: "GET", description: "The person's account page" },
  }),
  async (req: AssentryRequest, res) => {
    // req.assentryGrant = { userId, agentId, expiresAt }: who the agent acts for.
    // userId is stable for this person and specific to you: key your accounts on it.
    const result = await assentry.getProfile(req.assentryCredential!, ["name", "email"]);
    res.json({
      actingFor: req.assentryGrant!.userId,
      profile: result.valid ? result.profile : {},
      stillNeeded: result.valid ? result.missing ?? [] : [],
    });
  }
);

app.listen(3000);
  1. Deploy, then click Verify domain in the console. The SDK already serves the proof Assentry checks; until the domain is verified, agents can't sign in to your site.
The SDK talks to https://api.assentry.io by default. Only set baseUrl to point it at a local or test Assentry.

That's it. A browser agent sees a sign-in code at the bottom of your pages and approves it through its Assentry connection; the page then signs it in by itself. An agent calling your site directly gets a standard challenge:

Response with no credential
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="Assentry"
Link: </.well-known/assentry.json>; rel="assentry-agent-discovery"

{"error":"missing Assentry credential"}

…and an agent presenting a valid credential gets through:

Terminal
curl https://yoursite.com/account \
  -H "Authorization: Bearer <credential>"

SDK reference

@assentry/sdk is a server-side Node/TypeScript package. Express middleware is included; the Assentry client works with any framework.

new Assentry(options)

new Assentry({ apiKey: string, baseUrl?: string, partnerId?: string })
OptionDescription
apiKeyRequired. Your app's API key from the console. Server-side only.
baseUrlAssentry's API origin. Defaults to https://api.assentry.io; only set it for a local or test Assentry.
partnerIdYour app's Public ID from the console. Only needed for getAccountLinkUrl.

assentry.verify(credential)

verify(credential: string): Promise<VerifyResult>

Checks a credential with Assentry. Always a live check, so withdrawn permission takes effect immediately. A credential issued for a different site is refused with wrong_site.

userId and agentId are specific to your developer account: the same person always has the same userId across your apps, but every other developer sees a different one, so sites can't link a person between them. Key your accounts on userId.

VerifyResult
type VerifyResult =
  | { valid: true; userId: string; agentId: string; expiresAt: string }
  | { valid: false; reason: DenialReason };

Never throws: if Assentry can't be reached or rejects your API key, it returns { valid: false, reason: "assentry_unavailable" }.

assentry.getProfile(credential, fields)

getProfile(credential: string, fields: string[]): Promise<ProfileResult>

Returns the subset of fields that your app declared in the console and the person allowed for this grant (and has filled in). Anything else is simply omitted, not an error; fields your app didn't declare are listed in undeclaredFields so you can spot a missing declaration. Required details you declared but aren't getting are listed in missing: Assentry has asked the person for them. People declare these details themselves; Assentry checks the format, not the truth. Each value arrives in its type's format (see Details and types).

ProfileResult
type ProfileResult =
  | {
      valid: true;
      profile: Record<string, ProfileValue>;
      meta: Record<string, { source: "person" | "agent"; updatedAt: string }>;
      missing?: string[];
      undeclaredFields?: string[];
    }
  | { valid: false; reason: DenialReason };

assentry.downloadFile(file)

downloadFile(file: ProfileFile): Promise<{ bytes: Buffer; contentType: string; filename: string }>

Downloads a file detail. A file arrives in getProfile as { filename, contentType, size, sha256, url, expiresAt }; the url works once, within 5 minutes, and only with your API key, so a leaked link is useless. Each download appears in the person's activity.

requireGrant(assentry, options?)

requireGrant(assentry: Assentry, options?: RequireGrantOptions): RequestHandler

Express middleware that only lets requests with a valid credential through.

OutcomeResponse
No credential401 {"error":"missing Assentry credential"} + WWW-Authenticate: Bearer realm="Assentry"
Credential not valid403 {"error":"not authorised","reason":"…"}
ValidCalls next() with req.assentryGrant and req.assentryCredential set
OptionDescription
credentialHeaderRead the credential from a different header. Defaults to the standard Authorization: Bearer. Only change this with good reason.
discoverA DiscoverableRoute that lists this route in your discovery document. Omit to keep it unlisted.
Request properties
interface AssentryRequest extends express.Request {
  assentryGrant?: { userId: string; agentId: string; expiresAt: string };
  assentryCredential?: string; // reuse for assentry.getProfile()
}

getCredentialFromRequest(req, options?)

getCredentialFromRequest(req: Request, options?: { credentialHeader?: string }): string | null

Reads a credential without rejecting the request, for pages that accept either a normal logged-in human or an agent:

server.ts
import { declareDiscoverableRoute, getCredentialFromRequest } from "@assentry/sdk";

// This page also accepts a normal logged-in human, so it can't use
// requireGrant (which rejects any request without a credential).
declareDiscoverableRoute({ path: "/dashboard", method: "GET", description: "Account dashboard" });

app.get("/dashboard", async (req, res) => {
  if (req.session?.user) return res.send(renderDashboard(req.session.user));

  const credential = getCredentialFromRequest(req);
  if (!credential) return res.redirect("/login");

  const result = await assentry.verify(credential);
  if (!result.valid) return res.status(403).send(`Access denied: ${result.reason}`);

  res.send(renderDashboard({ assentryUserId: result.userId }));
});

assentryDiscovery(options)

assentryDiscovery({ partnerName: string, assentry?: Assentry }): RequestHandler

Mount once, before your routes. Always pass assentry: without it, only agents that send an Authorization header can get in, browser agents can't, and your domain can't be verified. See Agent discovery.

With assentry, it also servesWhat for
sign-in code on HTML pagesFor visitors who aren't signed in: the code, in visible text, a meta tag and an Assentry-Sign-In-Code header.
/assentry/sign-in/statusThe page checks this every few seconds and moves on once the agent has approved the code.
/assentry/sign-outEnds the agent's browser session on your site.
/.well-known/assentry-domain-verificationThe proof Assentry fetches when you click Verify domain.

declareDiscoverableRoute(route)

declareDiscoverableRoute({ path: string, method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", description: string }): void

Lists a route in your discovery document when it can't use requireGrant's discover option. Call it once at startup, next to the route.

assentry.getAccountLinkUrl(params) · assentry.exchangeLinkCode(code)

getAccountLinkUrl({ redirectUri: string, state: string }): string
exchangeLinkCode(code: string): Promise<{ userId: string }>

See Account linking. getAccountLinkUrl throws if the client was created without partnerId.

Lower-level sign-in calls

startSignIn(): Promise<{ requestId: string; code: string; expiresAt: string }>
collectSignIn(requestId: string): Promise<SignInCollectResult>
getDomainVerificationToken(): Promise<string | null>

What assentryDiscovery uses under the hood, for frameworks other than Express. Start a sign-in, show the code, and poll collectSignIn: once the agent approves it, you get the credential exactly once ({ status: "approved", credential, userId, agentId, expiresAt }); before that, pending; afterwards, expired or already_collected. Serve the verification token as plain text at /.well-known/assentry-domain-verification.

AssentryRequestError

class AssentryRequestError extends Error { status: number }

Thrown by exchangeLinkCode, downloadFile and the lower-level sign-in calls when Assentry rejects them: an invalid or revoked API key (401), a malformed request (400), an invalid link code (400), or a download link that was already used or has expired. verify and getProfile never throw: a credential that isn't valid, or an Assentry that can't be reached, comes back as { valid: false, reason }.

Details and types

In the console, tick the details your site needs and mark each required or optional. Every detail has one type, and every type one format, so you always know exactly what you'll receive. People fill their details in once; their agent can suggest missing ones, but nothing an agent suggests is shared until the person confirms it (meta.source tells you which way it came).

Your own details

For anything not in the list, add your own detail in the console: a key (e.g. membership_number), a label and one of the types above. Your custom details are only ever shared with your site, and when a person's agent signs in without one you marked required, Assentry asks the person to fill it in.

Agent discovery

An agent told “go check my account on acme.com” can't know your site accepts Assentry, and a well-behaved agent won't try to get past your login page. assentryDiscovery tells it, on every page, and gives it a way in that never involves typing anything into your site.

Agents in a browser: the sign-in code

  1. When someone who isn't signed in opens one of your pages, the SDK asks Assentry (with your API key) for a one-time code and shows it at the bottom of the page: Assentry sign-in code: asq_….
  2. The agent approves the code through its own Assentry connection, telling Assentry the domain in its address bar. Assentry only approves it if the code was issued to that verified domain, so a code copied from another site is refused. Approving is also what ties the code to the person the agent acts for.
  3. Your page notices within a few seconds; your server collects the result from Assentry, keeps the credential in an httpOnly cookie, and the page moves on to your first protected route. getCredentialFromRequest and requireGrant read that cookie, so your routes need no changes.

Codes are random (52 characters), single-use, tied to your site and valid for 5 minutes, and each visitor gets their own. Every later request is still checked with Assentry, so if the person withdraws permission or ends the sign-in, the agent is signed out on its next page. Your page uses a small inline script to check back; if your Content Security Policy blocks inline scripts, allow it.

Browser sign-in needs a verified domain. Until you verify yours in the console, no codes are shown.

Agents calling your site directly

  • Every response, including 404s and redirects, carries Link: </.well-known/assentry.json>; rel="assentry-agent-discovery", so an agent sees it wherever it lands first.
  • /.well-known/assentry.json explains both ways in and lists every route declared with discover or declareDiscoverableRoute. There's no separate list to keep in sync.
  • The agent fetches a credential for your domain from Assentry and sends it as Authorization: Bearer <credential>. Routes protected by requireGrant answer a request without one with the standard WWW-Authenticate: Bearer challenge.
GET /.well-known/assentry.json
{
  "assentry_version": "1",
  "partner": "Acme",
  "description": "This site accepts AI agents acting on a person's behalf, authorised and verified through Assentry (...).",
  "credential": {
    "header": "Authorization",
    "scheme": "Bearer",
    "instructions": "Present the Assentry credential your grant issued as 'Authorization: Bearer <credential>' on the routes below. No login, session or signup is needed or should be attempted."
  },
  "browser_sign_in": {
    "code_header": "Assentry-Sign-In-Code",
    "sign_out_path": "/assentry/sign-out",
    "instructions": "If you're driving a browser, pages show an 'Assentry sign-in code'. Approve it with your Assentry connection's approve_sign_in tool, passing this site's domain from the address bar; the page then signs you in by itself. Never type a credential into this site."
  },
  "routes": [
    { "path": "/account", "method": "GET", "description": "The person's account page" }
  ]
}

Account linking

Recognising returning people

Key your accounts on the userId that verify returns. It's the same for a person every time, whichever agent they use and however many times they renew or change their permission. Don't key on email: people can change it or choose not to share it. Store the email or name alongside the account if the person shares them, but find the account by userId.

People who already had an account

If a person already had an account on your site before they used Assentry, a credential alone doesn't say which of your accounts is theirs. You have two options:

  • Match it yourself: on their first visit through Assentry, use getProfile (e.g. their email) and your own logic to find the existing account, then save the userId on it so later visits are found by ID. Simple, no extra step for the person. Only match automatically on details you would trust from your own sign-up form; otherwise use explicit linking.
  • Explicit linking: a one-time confirmation by the person, while logged in to your site. Stronger assurance; after it, any credential for that Assentry user is proof of that specific account.

Setting up explicit linking

  1. In the console, add your callback URL under Account-linking redirect URIs. Assentry only ever redirects to an exact URI registered there.
  2. Pass your app's Public ID as partnerId.
server.ts
const assentry = new Assentry({
  apiKey: process.env.ASSENTRY_API_KEY!,
  partnerId: process.env.ASSENTRY_PARTNER_PUBLIC_ID!, // your app's Public ID, safe to expose
});

// 1. Your already-logged-in user clicks "Link my Assentry account".
app.get("/link/start", (req, res) => {
  // A fresh random value, kept in their session and checked on the way back,
  // so nobody can send your user a link that finishes someone else's linking.
  req.session.linkState = crypto.randomBytes(32).toString("base64url");
  res.redirect(
    assentry.getAccountLinkUrl({
      redirectUri: "https://yoursite.com/link/callback", // must be registered in the console
      state: req.session.linkState,                        // echoed back to you unchanged
    })
  );
});

// 2. Assentry sends them back with a one-time code (valid 60 seconds).
app.get("/link/callback", async (req, res) => {
  const expected = req.session.linkState;
  delete req.session.linkState;
  if (!expected || req.query.state !== expected) return res.status(400).send("Linking expired. Try again.");
  if (req.query.error === "access_denied") return res.send("Linking cancelled.");
  const { userId } = await assentry.exchangeLinkCode(String(req.query.code));
  await db.users.update(req.session.user.id, { assentryUserId: userId });
  res.send("Linked!");
});

The code is single-use and expires after 60 seconds. If the person declines, your callback receives ?error=access_denied&state=… instead of a code.

HTTP API

Not using Node? The SDK is a thin wrapper over these JSON endpoints. Call them from your server, never from a browser, with your API key in the x-assentry-api-key header (except the two public ones at the end).

Base URL
https://api.assentry.io

POST /verify

Request
curl -X POST https://api.assentry.io/verify \
  -H "Content-Type: application/json" \
  -H "x-assentry-api-key: $ASSENTRY_API_KEY" \
  -d '{"credential":"<credential>"}'
200, valid
{ "valid": true, "userId": "usr_…", "agentId": "agt_…", "expiresAt": "2026-11-04T09:00:00.000Z" }
200, not valid
{ "valid": false, "reason": "revoked" }

POST /profile

Request
curl -X POST https://api.assentry.io/profile \
  -H "Content-Type: application/json" \
  -H "x-assentry-api-key: $ASSENTRY_API_KEY" \
  -d '{"credential":"<credential>","fields":["name","email"]}'
200, valid
{
  "valid": true,
  "profile": { "name": "Ada Lovelace", "email": "ada@example.com" },
  "meta": {
    "name": { "source": "person", "updatedAt": "2026-11-06T09:00:00.000Z" },
    "email": { "source": "agent", "updatedAt": "2026-11-06T09:05:00.000Z" }
  },
  "missing": ["phone_number"]
}

fields must contain at least one key. You only get keys you declared under Required data that the person allowed and has filled in; others are left out. missing lists required keys you aren't getting (Assentry has asked the person), undeclaredFields any you asked for but didn't declare. File details come as { filename, contentType, size, sha256, url, expiresAt }. An invalid credential returns { "valid": false, "reason": … } like /verify.

GET /files/download/:token

The url of a file detail. Send your API key; the link works once, within 5 minutes, and returns the file as a download (Content-Disposition: attachment). 401 without your key, 404 once used or expired.

POST /sign-in-requests · POST /sign-in-requests/collect

Start a browser sign-in
curl -X POST https://api.assentry.io/sign-in-requests -H "x-assentry-api-key: $ASSENTRY_API_KEY"
# 201 { "requestId": "…", "code": "asq_…", "expiresAt": "…" }
# 403 if your app's domain isn't verified yet
Collect it
curl -X POST https://api.assentry.io/sign-in-requests/collect   -H "Content-Type: application/json" -H "x-assentry-api-key: $ASSENTRY_API_KEY"   -d '{"requestId":"…"}'
# { "status": "pending" }   until the agent approves the code
# { "status": "approved", "credential": "…", "userId": "usr_…", "agentId": "agt_…", "expiresAt": "…" }   once
# { "status": "expired" | "already_collected" | "not_found" | "denied" }

GET /domain-verification

Returns { "domain": "…", "token": "…" }: serve token as plain text at https://your-domain/.well-known/assentry-domain-verification, then click Verify domain.

Public: GET /fields · GET /.well-known/jwks.json

/fields lists the standard details with their types and formats (the table in Details and types). /.well-known/jwks.json publishes the public keys credentials are signed with (ES256), in case you want to check a signature yourself; still call /verify, which also catches withdrawn permission.

Request
curl -X POST https://api.assentry.io/link/token \
  -H "Content-Type: application/json" \
  -H "x-assentry-api-key: $ASSENTRY_API_KEY" \
  -d '{"code":"<code from your callback>"}'
200
{ "userId": "usr_…" }

The link flow itself starts with a browser redirect (not an API call) to https://api.assentry.io/link/authorize?partner_id=<Public ID>&redirect_uri=<registered URI>&state=<anything>.

Request errors (all endpoints)

StatusBodyMeaning
401{"error":"missing x-assentry-api-key header"}No API key sent.
401{"error":"invalid api key"}Unknown, rotated-out or revoked key.
400{"error":{ … }}Malformed body on /verify or /profile (field-level details included).
400{"error":"invalid_request"}/link/token: missing code.
400{"error":"invalid_grant"}/link/token: code unknown, already used, expired, or issued to another app.
403{"error":"this app's domain isn't verified yet…"}/sign-in-requests: verify your domain in the console first.

Errors & denial reasons

When a credential isn't accepted, verify and getProfile return { valid: false, reason } with one of these DenialReason values:

reasonMeaningWhat to do
invalid_credentialMalformed, tampered with, or past its own expiry.Treat as unauthenticated. The agent should fetch a fresh credential.
grant_not_foundThe grant behind it no longer exists.Treat as unauthenticated.
revokedThe person revoked this authorisation.Deny. Only the person can authorise again.
agent_revokedThe person disconnected this agent entirely.Deny.
expiredThe grant's time window has ended.Deny. The person can issue a new grant.
wrong_siteThe credential was issued for a different site, or your app's domain isn't verified.Deny. The agent needs a credential for your site.
sign_in_endedThe person ended this sign-in from their Sign-ins screen.Deny. The agent can sign in again if its permission still holds.
assentry_unavailableAssentry couldn't be reached, or rejected your API key (SDK only).Deny for now and check your API key.

In every case, fall back to however your site normally treats an unauthenticated visitor.

Managing keys: if a key leaks, rotate it in the console. The old key stops working on the very next request. Revoking an app disables its key permanently.