Integration Walkthrough
This guide walks through the complete Allowly lifecycle for an AI agent that reads and sends email on a user's behalf. Every step maps directly to an API call.
The flow
User authorizes agent
│
▼
POST /v1/authorizations ← store authorization_id
│
▼
Agent wants to act
│
▼
POST /v1/check ← before every action
│
┌────┴────┐
allow confirm deny → block
│ │
│ ask user to approve
│ │
│ POST /v1/confirmations/{nonce}
│ │
│ re-check → allow
▼
run action
│
▼
GET /v1/receipts/{id} ← poll for signed receipt
│
▼
verify offline ← Ed25519, no network call
---
Step 1 — Create an authorization
When the user grants your agent permission, create an authorization and store the authorization_id.
Python
from allowly import Allowly
from allowly.identifiers import from_email
allowly = Allowly(api_key=os.environ["ALLOWLY_KEY"])
allowly_user_id = from_email(user.email, pepper=os.environ["ALLOWLY_PII_PEPPER"])
authorization = await allowly.authorizations.create(
user_id=allowly_user_id,
agent_id="my-email-agent",
scopes=["email.read", "email.send"],
expires_at="2026-12-31T00:00:00Z",
metadata={"source": "onboarding"},
)
await db.users.update(user.id, allowly_authorization_id=authorization.authorization_id)
# authorization.receipt.status == "pending" — signed asynchronously
TypeScript
import { Allowly, identifiers } from "@allowly/sdk";
const allowly = new Allowly({ apiKey: process.env.ALLOWLY_KEY! });
const allowlyUserId = identifiers.fromEmail(user.email, {
pepper: process.env.ALLOWLY_PII_PEPPER!,
});
const authorization = await allowly.authorizations.create({
userId: allowlyUserId,
agentId: "my-email-agent",
scopes: ["email.read", "email.send"],
expiresAt: "2026-12-31T00:00:00Z",
metadata: { source: "onboarding" },
});
await db.users.update(user.id, { allowlyAuthorizationId: authorization.authorizationId });
Use your own opaque internal ID when you have one. The SDK email helper is for apps that need stable email-based lookup without putting raw email into Allowly receipts.
For estimated-cost workflows, set an authorization-level cap at creation time:
Python
authorization = await allowly.authorizations.create(
user_id=allowly_user_id,
agent_id="research-agent",
scopes=["llm.enrich"],
expires_at="2026-12-31T00:00:00Z",
budget_limit_micros=50_000_000, # $50.00
)
TypeScript
const authorization = await allowly.authorizations.create({
userId: allowlyUserId,
agentId: "research-agent",
scopes: ["llm.enrich"],
expiresAt: "2026-12-31T00:00:00Z",
budgetLimitMicros: 50_000_000, // $50.00
});
---
Step 2 — Check before every action
Every action your agent takes must go through /check. This is what creates the audit trail.
Python
result = await allowly.check(
authorization_id=user.allowly_authorization_id,
scopes=["email.send"],
resource="gmail:thread:abc123",
context={"initiated_by": "user", "origin": "chat"},
)
scope_result = result.results["email.send"]
if scope_result.decision == "allow":
await send_email(thread_id="abc123")
elif scope_result.decision == "confirm":
# User must approve this specific action before it proceeds
approved = await ask_user(scope_result.confirm_prompt_hint)
await allowly.confirmations.approve(scope_result.confirm_nonce, approved=approved)
if approved:
# Re-check — now returns allow
retry = await allowly.check(authorization_id=user.allowly_authorization_id, scopes=["email.send"])
if retry.results["email.send"].decision == "allow":
await send_email(thread_id="abc123")
else:
match scope_result.reason:
case "rate_limit_exceeded":
raise Exception("Daily limit reached — try again tomorrow.")
case "authorization_revoked" | "authorization_expired":
raise Exception(f"Authorization no longer valid: {scope_result.reason}")
case _:
raise Exception(f"Allowly denied: {scope_result.reason}")
TypeScript
const result = await allowly.check({
authorizationId: user.allowlyAuthorizationId,
scopes: ["email.send"],
resource: "gmail:thread:abc123",
context: { initiated_by: "user", origin: "chat" },
});
const scopeResult = result.results["email.send"];
if (scopeResult.decision === "allow") {
await sendEmail({ threadId: "abc123" });
} else if (scopeResult.decision === "confirm") {
const approved = await askUser(scopeResult.confirmPromptHint);
await allowly.confirmations.approve(scopeResult.confirmNonce, { approved });
if (approved) {
const retry = await allowly.check({ authorizationId: user.allowlyAuthorizationId, scopes: ["email.send"] });
if (retry.results["email.send"].decision === "allow") await sendEmail({ threadId: "abc123" });
}
} else {
switch (scopeResult.reason) {
case "rate_limit_exceeded":
throw new Error("Daily limit reached — try again tomorrow.");
case "authorization_revoked":
case "authorization_expired":
throw new Error(`Authorization no longer valid: ${scopeResult.reason}`);
default:
throw new Error(`Allowly denied: ${scopeResult.reason}`);
}
}
scopeResult.receipt is usually a pending envelope at this point — the Ed25519 signature is produced asynchronously by the server.
Optional Pro+ budgeted check
If a Pro or Enterprise authorization was created with budget_limit_micros, each /check must include an estimate. For launch, budgeted checks use one scope at a time.
Python
result = await allowly.check(
authorization_id=user.allowly_authorization_id,
scopes=["llm.enrich"],
estimated_cost_micros=24_000,
)
scope_result = result.results["llm.enrich"]
if scope_result.reason == "budget_exceeded":
raise Exception("This action would exceed the authorization budget.")
TypeScript
const result = await allowly.check({
authorizationId: user.allowlyAuthorizationId,
scopes: ["llm.enrich"],
estimatedCostMicros: 24_000,
});
const scopeResult = result.results["llm.enrich"];
if (scopeResult.reason === "budget_exceeded") {
throw new Error("This action would exceed the authorization budget.");
}
---
Step 3 — Fetch and verify the signed receipt
After the action, fetch the receipt and verify its signature offline. This is the proof of authorization.
Python
from allowly.verify import verify_receipt, load_keys_from_json
import httpx
# Fetch your workspace's public keys once and cache them
keys_doc = httpx.get(
f"https://api.allowly.ai/v1/workspaces/{workspace_id}/keys"
).json()
keys = load_keys_from_json(keys_doc)
# Poll until signed (default: 1s interval, 30s timeout)
signed_receipt = await allowly.receipts.fetch_signed(scope_result.receipt.receipt_id)
# Verify offline — no API call
verify_receipt(signed_receipt, keys) # raises VerificationError if invalid
TypeScript
import { verifyReceipt, loadKeysFromJson, VerificationError } from "@allowly/sdk";
// Fetch your workspace's public keys once and cache them
const keysDoc = await fetch(
`https://api.allowly.ai/v1/workspaces/${workspaceId}/keys`
).then(r => r.json());
const keys = loadKeysFromJson(keysDoc);
// Poll until signed
if (scopeResult.receipt.status !== "pending") {
throw new Error("Receipt is already signed");
}
const signedReceipt = await allowly.receipts.fetchSigned(scopeResult.receipt.receiptId);
// Verify offline — no API call
try {
await verifyReceipt(signedReceipt, keys);
} catch (e) {
if (e instanceof VerificationError) throw new Error(`Invalid receipt: ${e.message}`);
}
---
Step 4 — MCP server integration
If your agent runs as an MCP server, use the middleware to gate every tool call automatically. The middleware handles steps 2 (check) and the confirm flow on every tool invocation.
Python
from mcp.server.fastmcp import FastMCP
from allowly import AllowlyMCPMiddleware
mcp = FastMCP("my-email-agent")
mcp.add_middleware(AllowlyMCPMiddleware(
api_key=os.environ["ALLOWLY_KEY"],
authorization_id_fn=lambda user_id: db.get_authorization_id(user_id),
))
@mcp.tool()
async def send_email(user_id: str, thread_id: str, body: str) -> str:
# Only reached if Allowly returned allow
return await gmail.send(thread_id=thread_id, body=body)
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { AllowlyMCPMiddleware } from "@allowly/sdk/mcp";
import { z } from "zod";
const mcp = new McpServer({ name: "my-email-agent", version: "1.0" });
const allowly = new AllowlyMCPMiddleware({
apiKey: process.env.ALLOWLY_KEY!,
authorizationIdFn: (userId) => db.getAuthorizationId(userId),
});
allowly.attach(mcp.server);
mcp.tool(
"send_email",
{ user_id: z.string(), thread_id: z.string(), body: z.string() },
async ({ user_id, thread_id, body }) => {
// Only reached if Allowly returned allow
const result = await gmail.send({ threadId: thread_id, body });
return { content: [{ type: "text", text: result.messageId }] };
}
);
---
Step 5 — Revoke when the user withdraws authorization
result = await allowly.authorizations.revoke(user.allowly_authorization_id, revoked_by="user")
# result.receipt.status == "pending" — revocation is also receipted
const result = await allowly.authorizations.revoke(user.allowlyAuthorizationId, { revokedBy: "user" });
// result.receipt.status === "pending"
After revocation, all subsequent check() calls for that authorization return deny.