Your API serves agents. An agent hits POST /purchase on behalf of a user who may or may not be KYC-verified, old enough, in an allowed jurisdiction, or on a sanctions list. You need to verify the human behind the agent before you deliver, without breaking the agent’s retry loop.
This guide shows the drop-in pattern using @agent-score/gate (Node.js) or agentscore-gate (Python). We’ll walk through Martin Estate’s wine commerce API as the worked example — same pattern applies to any merchant.
The pattern
Three lines of middleware config gate your route:
import { agentscoreGate } from '@agent-score/gate/hono';
app.use('/purchase', agentscoreGate({
apiKey: process.env.AGENTSCORE_API_KEY!,
userAgent: `my-api/${VERSION}`,
requireKyc: true,
requireSanctionsClear: true,
minAge: 21,
allowedJurisdictions: ['US'],
createSessionOnMissing: { apiKey: process.env.AGENTSCORE_API_KEY! },
}));
Here’s what happens:
-
Agent hits
/purchase with an X-Operator-Token or X-Wallet-Address header. The gate extracts the identity, calls POST /v1/assess with your policy, and either passes the request through (with assess data attached) or returns a 403 with reason codes.
-
If no identity header is present, the gate auto-mints a verification session via
POST /v1/sessions and returns a 403 carrying verify_url, session_id, poll_secret, poll_url, and agent-polling instructions. The agent opens verify_url for the user, polls poll_url with X-Poll-Secret: {poll_secret} in the background, and retries once the user finishes Stripe Identity. Zero additional merchant code.
-
If identity fails a compliance check (age, sanctions, jurisdiction), the default 403 includes the denial reasons + a
verify_url for resolvable cases (e.g. KYC expired).
No hand-rolled fetches, no session-creation boilerplate, no decision === 'deny' branches.
End-to-end Hono walkthrough
Martin Estate’s POST /purchase is the canonical integration. What follows is a trimmed version of their src/routes/purchase.ts with the exact gate setup.
1. Install
bun install @agent-score/gate hono
# or: npm install @agent-score/gate hono
Grab an API key at agentscore.sh/dashboard (free tier covers hobby usage; paid tier enables /v1/assess policy evaluation).
2. Mount the gate
import { Hono } from 'hono';
import { agentscoreGate, getAgentScoreData } from '@agent-score/gate/hono';
const app = new Hono();
app.use('/purchase', agentscoreGate({
apiKey: process.env.AGENTSCORE_API_KEY!,
// Prepends your app's identifier to outbound User-Agent for traceability in
// AgentScore's server logs. Format: "{userAgent} (@agent-score/gate@1.8.0)"
userAgent: `martin-estate/${VERSION}`,
// Wine-specific compliance policy. See the policy reference below.
requireKyc: true,
requireSanctionsClear: true,
minAge: 21,
allowedJurisdictions: ['US'],
// Auto-create a verification session when no identity header is present.
createSessionOnMissing: {
apiKey: process.env.AGENTSCORE_API_KEY!,
context: 'wine-purchase',
productName: 'Martin Estate wine',
},
}));
That’s it for the compliance layer. Everything else below is your existing route code.
3. Access assess data in the handler
app.post('/purchase', async (c) => {
// The gate populated this via c.set('agentscore', data) before calling next().
const assess = getAgentScoreData(c);
console.log('identity method:', assess?.identity_method); // 'wallet' | 'operator_token'
console.log('policy checks:', assess?.policy_result?.checks);
// ... your normal payment + fulfillment logic ...
return c.json({ order_id: '...', status: 'completed' });
});
getAgentScoreData(c) returns the full /v1/assess response — decision, decision_reasons, operator_verification, policy_result, and any resolvable verify_url. Record it alongside the order for audit purposes.
4. What the agent sees on 403
When identity is missing, the default response is:
{
"error": "identity_verification_required",
"verify_url": "https://agentscore.sh/verify?session=sess_abc123",
"session_id": "sess_abc123",
"poll_secret": "ps_secret_xyz",
"poll_url": "https://api.agentscore.sh/v1/sessions/sess_abc123",
"agent_instructions": "Please complete identity verification at the verify_url."
}
The agent’s job:
- Show
verify_url to the user (it’s a ready-to-open URL with the session token embedded — don’t modify it).
- While the user verifies, poll
poll_url every 5 seconds with header X-Poll-Secret: {poll_secret}.
- When the poll returns
status: "verified", extract operator_token from the response.
- Retry
POST /purchase with header X-Operator-Token: {operator_token}.
The retry should pass through — same request body, just with the verified identity.
If identity is present but fails policy (e.g. age_insufficient, sanctions_flagged), the 403 has error: "wallet_not_trusted" with reasons and, where the user can self-remediate, a verify_url for re-verification.
5. Testing with mocked gate outcomes
The gate calls POST /v1/assess via global.fetch. Mock it in Vitest:
import { describe, expect, it, vi } from 'vitest';
import app from '../src/app';
describe('POST /purchase', () => {
it('returns 403 when identity is missing', async () => {
// Mock the session-creation call the gate makes on missing identity.
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
json: vi.fn().mockResolvedValueOnce({
session_id: 'sess_test',
verify_url: 'https://agentscore.sh/verify?session=sess_test',
poll_secret: 'ps_test',
poll_url: 'https://api.agentscore.sh/v1/sessions/sess_test',
expires_at: '2026-04-22T13:00:00Z',
}),
});
const res = await app.request('/purchase', {
method: 'POST',
body: JSON.stringify({ product_id: '...', quantity: 1 }),
});
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe('identity_verification_required');
expect(body.session_id).toBe('sess_test');
expect(body.poll_url).toBe('https://api.agentscore.sh/v1/sessions/sess_test');
});
it('lets the request through when identity passes policy', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
json: vi.fn().mockResolvedValueOnce({
decision: 'allow',
decision_reasons: [],
operator_verification: { level: 'kyc_verified' },
}),
});
const res = await app.request('/purchase', {
method: 'POST',
headers: { 'X-Operator-Token': 'opc_test' },
body: JSON.stringify({ product_id: '...', quantity: 1 }),
});
expect(res.status).toBe(200);
});
});
The same pattern in every framework
Each adapter ships with the same option shape. Pick yours:
Express
import express from 'express';
import { agentscoreGate } from '@agent-score/gate/express';
const app = express();
app.use('/purchase', agentscoreGate({
apiKey: process.env.AGENTSCORE_API_KEY!,
requireKyc: true, minAge: 21, allowedJurisdictions: ['US'],
createSessionOnMissing: { apiKey: process.env.AGENTSCORE_API_KEY! },
}));
Next.js App Router
// app/api/purchase/route.ts
import { withAgentScoreGate } from '@agent-score/gate/nextjs';
export const POST = withAgentScoreGate(
{
apiKey: process.env.AGENTSCORE_API_KEY!,
requireKyc: true, minAge: 21, allowedJurisdictions: ['US'],
createSessionOnMissing: { apiKey: process.env.AGENTSCORE_API_KEY! },
},
async (_req, { data }) => Response.json({ score: data!.score.value }),
);
Fastify
import Fastify from 'fastify';
import agentscoreGate from '@agent-score/gate/fastify';
const app = Fastify();
await app.register(agentscoreGate, {
apiKey: process.env.AGENTSCORE_API_KEY!,
requireKyc: true, minAge: 21, allowedJurisdictions: ['US'],
});
Web Fetch (Cloudflare Workers / Deno / Bun / edge)
import { createAgentScoreGate } from '@agent-score/gate/web';
const guard = createAgentScoreGate({
apiKey: env.AGENTSCORE_API_KEY,
requireKyc: true, minAge: 21, allowedJurisdictions: ['US'],
});
export default {
async fetch(req: Request) {
const r = await guard(req);
if (!r.allowed) return r.response;
return handle(req, r.data);
},
};
FastAPI (native Depends())
from fastapi import Depends, FastAPI
from agentscore_gate.fastapi import AgentScoreGate, get_assess_data
app = FastAPI()
gate = AgentScoreGate(
api_key=os.environ['AGENTSCORE_API_KEY'],
require_kyc=True, min_age=21, allowed_jurisdictions=['US'],
)
@app.post('/purchase', dependencies=[Depends(gate)])
async def purchase(assess = Depends(get_assess_data)):
return {'score': assess['score']['value']}
Flask
from agentscore_gate.flask import agentscore_gate
agentscore_gate(
app,
api_key=os.environ['AGENTSCORE_API_KEY'],
require_kyc=True, min_age=21, allowed_jurisdictions=['US'],
)
Django
# settings.py
MIDDLEWARE = ['agentscore_gate.django.AgentScoreMiddleware']
AGENTSCORE_GATE = {
'api_key': os.environ['AGENTSCORE_API_KEY'],
'require_kyc': True, 'min_age': 21, 'allowed_jurisdictions': ['US'],
}
Starlette, Litestar, Quart all work via the generic ASGI adapter: from agentscore_gate import AgentScoreGate. AIOHTTP and Sanic ship native adapters at agentscore_gate.aiohttp / agentscore_gate.sanic.
Compliance policy reference
All policy fields are optional — set only what your vertical requires.
| Field | Type | Examples | Description |
|---|
requireKyc | boolean | true | Require a completed Stripe Identity verification |
requireSanctionsClear | boolean | true | Fail on OFAC / UN / EU sanctions matches |
minAge | number | 18, 21 | Minimum age bracket enforced by the KYC provider |
allowedJurisdictions | string[] | ['US'], ['US', 'CA'] | Allowlist of ISO country codes; all others denied |
blockedJurisdictions | string[] | ['KP', 'IR', 'CU'] | Blocklist; takes precedence over allowedJurisdictions |
Common vertical policies
Wine / alcohol / age-restricted goods (Martin Estate):
{ requireKyc: true, requireSanctionsClear: true, minAge: 21, allowedJurisdictions: ['US'] }
Financial services / lending / regulated crypto:
{ requireKyc: true, requireSanctionsClear: true, minAge: 18 }
Restricted exports (US):
{ requireSanctionsClear: true, blockedJurisdictions: ['KP', 'IR', 'CU', 'RU', 'SY'] }
Marketplace trust gate (non-regulated):
{ requireKyc: true } // just prove the agent is backed by a real human
Advanced: per-request session context
For the flagship case — where the verify page should show the specific product the agent was buying — use the getSessionOptions and onBeforeSession hooks:
app.use('/purchase', agentscoreGate({
apiKey: process.env.AGENTSCORE_API_KEY!,
requireKyc: true, minAge: 21, allowedJurisdictions: ['US'],
createSessionOnMissing: {
apiKey: process.env.AGENTSCORE_API_KEY!,
// Compute session options per-request. Hook receives the framework context so
// it can read body/headers/state a prior middleware stashed.
getSessionOptions: async (c) => {
const body = await c.req.json();
const product = await lookupProduct(body.product_id);
return { productName: product.name }; // "2022 Cabernet Sauvignon" not "wine"
},
// Side-effect hook that runs after the session mints. Return value merges
// into the 403 response — use it to attach a merchant-specific resume token
// (e.g. a pending-order id).
onBeforeSession: async (c, session) => {
const pendingOrderId = await createPendingOrder(await c.req.json(), session);
return { order_id: pendingOrderId };
},
},
}));
Martin Estate uses this exact pattern to pre-create pending_identity orders before the 403 fires; agents can then retry with just { operator_token, order_id } instead of re-sending the full body.
Post-payment: capture the signer wallet
After a successful payment, report the wallet that signed it back to AgentScore. Builds a cross-merchant credential↔wallet profile: the agent’s opc_... token you gated on gets linked to every wallet it’s been observed paying from, across every merchant using AgentScore.
import { captureWallet } from '@agent-score/gate/hono';
app.post('/purchase', async (c) => {
// ... compliance gate passed, payment settled ...
const signer = extractPaymentSigner(paymentCredential); // e.g. EIP-3009 `from`
await captureWallet(c, {
walletAddress: signer,
network: 'evm', // or 'solana'
idempotencyKey: paymentIntentId, // prevents retry double-count
});
return c.json({ ok: true });
});
Fire-and-forget — no-ops silently if the request was wallet-authenticated (no operator_token to link) or the API call fails. See the Credentials reference for the endpoint shape.
Observe denials in the dashboard
Every denial is logged against your API key. Open Dashboard → Usage to see:
- Total gated requests, allow/deny split
- Top denial reason codes (so you can tune
minAge, allowedJurisdictions, etc.)
- Sessions created via
createSessionOnMissing, completion rate
Wallets captured via captureWallet surface at Dashboard → Identity as a unified list with source labels (claimed, captured, both).
Live example — buy a wine
Drop this into your agent:
“Buy me wine from agents.martinestate.com.”
That’s the whole prompt. Your agent will ask which one, ask for a shipping address, discover the product, handle identity verification, pay, and ship real wine to your door — all gated by the five-line config above.
This needs an agent that can make HTTP calls — Claude Code, a Slack/Discord agent like OpenClaw, or any custom SDK-driven agent. The chat-only surfaces (chatgpt.com, claude.ai) won’t work.
Next steps