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.