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:
| Concept | What it is |
|---|---|
| Person | Someone with an Assentry account. They own their identity and decide which agents can act for them. |
| Agent | An AI assistant (Claude, ChatGPT, …) the person connected to Assentry. It never holds the person's passwords. |
| Grant | The person's authorisation for one agent: which profile fields it may disclose, and until when. The person can revoke it at any time. |
| Credential | A 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.
Every verification and profile request is recorded in the person's own Assentry audit log.
Quickstart
- Create a developer account.
- 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. - Under Required data, tick what your site needs and mark each detail required or optional.
- Install the SDK:
npm install @assentry/sdkSet your key as an environment variable on your server (never in browser code):
ASSENTRY_API_KEY=assentry_your_key_hereMake the site discoverable, and protect a route:
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);- 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.
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:
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:
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 })| Option | Description |
|---|---|
apiKey | Required. Your app's API key from the console. Server-side only. |
baseUrl | Assentry's API origin. Defaults to https://api.assentry.io; only set it for a local or test Assentry. |
partnerId | Your 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.
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).
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.
| Outcome | Response |
|---|---|
| No credential | 401 {"error":"missing Assentry credential"} + WWW-Authenticate: Bearer realm="Assentry" |
| Credential not valid | 403 {"error":"not authorised","reason":"…"} |
| Valid | Calls next() with req.assentryGrant and req.assentryCredential set |
| Option | Description |
|---|---|
credentialHeader | Read the credential from a different header. Defaults to the standard Authorization: Bearer. Only change this with good reason. |
discover | A DiscoverableRoute that lists this route in your discovery document. Omit to keep it unlisted. |
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 | nullReads a credential without rejecting the request, for pages that accept either a normal logged-in human or an agent:
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 }): RequestHandlerMount 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 serves | What for |
|---|---|
sign-in code on HTML pages | For visitors who aren't signed in: the code, in visible text, a meta tag and an Assentry-Sign-In-Code header. |
/assentry/sign-in/status | The page checks this every few seconds and moves on once the agent has approved the code. |
/assentry/sign-out | Ends the agent's browser session on your site. |
/.well-known/assentry-domain-verification | The proof Assentry fetches when you click Verify domain. |
declareDiscoverableRoute(route)
declareDiscoverableRoute({ path: string, method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", description: string }): voidLists 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 }): stringexchangeLinkCode(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
- 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_…. - 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.
- 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.
getCredentialFromRequestandrequireGrantread 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.
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.jsonexplains both ways in and lists every route declared withdiscoverordeclareDiscoverableRoute. 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 byrequireGrantanswer a request without one with the standardWWW-Authenticate: Bearerchallenge.
{
"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 theuserIdon 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
- In the console, add your callback URL under Account-linking redirect URIs. Assentry only ever redirects to an exact URI registered there.
- Pass your app's Public ID as
partnerId.
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).
https://api.assentry.ioPOST /verify
curl -X POST https://api.assentry.io/verify \
-H "Content-Type: application/json" \
-H "x-assentry-api-key: $ASSENTRY_API_KEY" \
-d '{"credential":"<credential>"}'{ "valid": true, "userId": "usr_…", "agentId": "agt_…", "expiresAt": "2026-11-04T09:00:00.000Z" }{ "valid": false, "reason": "revoked" }POST /profile
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"]}'{
"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
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 yetcurl -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.
POST /link/token
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>"}'{ "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)
| Status | Body | Meaning |
|---|---|---|
| 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:
| reason | Meaning | What to do |
|---|---|---|
invalid_credential | Malformed, tampered with, or past its own expiry. | Treat as unauthenticated. The agent should fetch a fresh credential. |
grant_not_found | The grant behind it no longer exists. | Treat as unauthenticated. |
revoked | The person revoked this authorisation. | Deny. Only the person can authorise again. |
agent_revoked | The person disconnected this agent entirely. | Deny. |
expired | The grant's time window has ended. | Deny. The person can issue a new grant. |
wrong_site | The 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_ended | The person ended this sign-in from their Sign-ins screen. | Deny. The agent can sign in again if its permission still holds. |
assentry_unavailable | Assentry 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.