PortaldLet AI agents trade stocks and make payments — with human approval
Give your AI agent permission to trade stocks on your behalf. Set daily limits, review orders before execution, and maintain full control.
User links their Alpaca account to Portald
Choose daily limit: $100, $500, or unlimited auto-approve
AI submits orders via API — within limit auto-executes, else queued
Review and approve trades over the daily limit in dashboard
POST https://portald.ai/api/agent-actions/ingest
Authorization: Bearer {session_token}
Content-Type: application/json
{
"action_type": "trading.alpaca.order",
"action_payload": {
"symbol": "PLTR",
"qty": "10",
"side": "buy",
"type": "market",
"time_in_force": "day"
},
"idempotency_key": "unique-order-id-123"
}// Auto-approved (within daily limit):
{
"action_id": "abc123",
"status": "approved",
"approved": true,
"execution_id": "alpaca-order-xyz"
}
// Requires approval (over limit):
{
"action_id": "abc123",
"status": "pending",
"approved": false,
"poll_after_ms": 5000
}GET https://portald.ai/api/agent/portfolio
Authorization: Bearer {session_token}
// Response:
{
"account": {
"portfolioValue": "100000.00",
"buyingPower": "50000.00",
"cash": "25000.00"
},
"positions": [
{
"symbol": "PLTR",
"quantity": 100,
"avgEntryPrice": 25.50,
"currentPrice": 28.00,
"unrealizedPnL": 250.00,
"unrealizedPnLPercent": 9.8
}
],
"accountType": "paper"
}View portfolio only
$100/day auto-approve
$500/day auto-approve
Unlimited auto-approve
Log in or create a Portald account (just email + password)
Cloudflared creates a public URL for your localhost
Manifest, webhook handler, and action endpoints
Start your dev server and test with AI agents
$ npx portald setup
Portald Setup
✔ Detected nextjs-app (src/)
✔ Found cloudflared in PATH
✔ Authenticated
✔ Tunnel ready: https://random-words.trycloudflare.com
✔ Site registered
✔ Wrote .env.local
✔ Generated routes
Setup complete! (Development mode)
Tunnel URL: https://random-words.trycloudflare.com
Site ID: site_abc123
Your integration is ready for testing.
When ready to accept payments, visit:
https://www.portald.ai/merchant/dashboard
Tunnel is active. Press Ctrl+C to stop.Customer: "Hey Claude, order me coffee from CoffeeShop.com"
AI Agent Portald Your Store
│ │ │
│ 1. Submit purchase │ │
│ ───────────────────────>│ │
│ │ │
│ 2. User gets push notification │
│ "Approve $24.99?" │
│ │ │
│ 3. User taps "Approve" │
│ │ │
│ 4. Portald charges card │
│ │ │
│ │ 5. Webhook to Zapier │
│ │ ─────────────────────────>│
│ │ │
│ │ 6. Zapier creates
│ │ Shopify order
│ │ │
│<─────────────────────────│<───────────────────────────│
│ "Order placed!" │If you're building a custom integration with code:
AI Agent Portald Your Server
│ │ │
│ 1. GET /products.json │ │
│ ─────────────────────────────────────────────────────>│
│ <─────────────────────── product list ───────────────│
│ │ │
│ 2. POST /ingest │ │
│ { product, amount } │ │
│ ───────────────────────>│ │
│ │ │
│ 3. User approves │
│ │ │
│ 4. Charge card │
│ │ │
│ │ 5. POST /webhook │
│ │ { status: "executed" } │
│ │ ─────────────────────────>│
│ │ │
│ │ 6. Fulfill order
│<─────────────────────────│<───────────────────────────│Portald is a protocol, not a rigid API. You can wrap any action on your site - purchases, bookings, subscriptions, API calls, file uploads, whatever you want agents to request with human approval.
The manifest at /.well-known/portald.json tells agents what actions your site supports. Define any action types you want, pointing to any endpoints on your site:
// Example: E-commerce site
{
"actions": [
{ "type": "checkout.create", "endpoint": "/api/checkout", "risk_level": "medium" },
{ "type": "subscription.start", "endpoint": "/api/subscribe", "risk_level": "high" }
]
}
// Example: SaaS API
{
"actions": [
{ "type": "api.call", "endpoint": "/v1/generate", "risk_level": "low" },
{ "type": "credits.purchase", "endpoint": "/v1/billing/add-credits", "risk_level": "medium" }
]
}
// Example: Booking platform
{
"actions": [
{ "type": "booking.create", "endpoint": "/bookings/new", "risk_level": "medium" },
{ "type": "booking.cancel", "endpoint": "/bookings/cancel", "risk_level": "low" }
]
}You don't need special Portald endpoints. Wrap your existing functions - the endpoint just needs to return something the agent can submit to Portald for approval:
// Before: Your existing checkout function
async function createOrder(items, address) {
const order = await db.orders.create({ items, address });
const total = calculateTotal(items, address);
return { orderId: order.id, total };
}
// After: Same function, exposed as a Portald action endpoint
export async function POST(req: Request) {
const { items, shipping_address } = await req.json();
// Call your existing code!
const result = await createOrder(items, shipping_address);
// Return in a format agents can submit to Portald
return Response.json({
order_id: result.orderId,
total_cents: result.total
});
}When Portald executes an approved action, it sends a webhook to your site. Verify the signature, then fulfill whatever the action requires:
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("x-portald-signature");
// ALWAYS verify signature
if (!verifySignature(body, signature)) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(body);
if (event.type === "action.executed") {
const { action_type, action_payload, amount_cents } = event;
// Handle different action types
switch (action_type) {
case "checkout.create":
await fulfillOrder(action_payload.order_id);
break;
case "subscription.start":
await activateSubscription(action_payload.plan_id);
break;
case "booking.create":
await confirmBooking(action_payload.booking_id);
break;
}
}
return Response.json({ received: true });
}Always verify webhook signatures. Without verification, attackers can send fake webhooks and get free products.
Portald signs every webhook with HMAC-SHA256:
Header: X-Portald-Signature: t=1707700000,v1=abc123...
Signature = HMAC-SHA256(
key: PORTALD_WEBHOOK_SECRET,
message: timestamp + "." + body
)function verifySignature(payload: string, signature: string): boolean {
const parts = signature.split(",");
const timestamp = parts.find(p => p.startsWith("t="))?.slice(2);
const sig = parts.find(p => p.startsWith("v1="))?.slice(3);
if (!timestamp || !sig) return false;
// Reject if older than 5 minutes
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return false;
}
const expected = crypto
.createHmac("sha256", process.env.PORTALD_WEBHOOK_SECRET!)
.update(timestamp + "." + payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(sig),
Buffer.from(expected)
);
}Risk levels determine whether payments auto-approve or require human confirmation:
| Level | Behavior | Use Case |
|---|---|---|
low | Can auto-approve (within user limits) | Small purchases under $15 |
medium | Always requires approval | Most purchases |
high | Always requires approval | Large purchases, subscriptions |
Set the risk level in your manifest for each action type.
Visit your merchant dashboard and click "Connect Stripe" to receive real payments.
Production sites require DNS verification to prevent impersonation. Add this TXT record:
Host: _portald-verify
Value: portald-site-verification=<your-token>Deploy your site normally. The generated routes work in production without changes.
Development vs Production: In dev mode (tunnel URLs), no real money moves. Connect Stripe and verify your domain to accept real payments.
| Framework | Status |
|---|---|
| Next.js App Router | Supported |
| Next.js Pages Router | Coming soon |
| Express.js | Supported |
| Fastify | Coming soon |
| Other Node.js | Manual setup (see below) |
If your framework isn't auto-detected, you need three HTTP endpoints:
GET /.well-known/portald.json - Return your manifestPOST /api/portald/webhook - Receive payment eventsPOST /api/portald/actions/checkout - Return quotesRun npx portald setup anyway to get credentials, then implement the endpoints manually.
Before attempting a purchase, check if a website supports Portald using our public discovery endpoint:
// Check if a website is Portald-enabled
const discovery = await fetch(
"https://portald.ai/api/discover?domain=example-coffee.com"
).then(r => r.json());
// Response:
{
"enabled": true,
"siteId": "site_abc123",
"merchantName": "Example Coffee",
"paymentProvider": "SQUARE", // or "STRIPE"
"verified": true,
"actions": [
{
"type": "payment.charge",
"description": "Process a payment for items from the menu",
"requires_approval": true
}
],
"locations": [
{ "id": "L8T5TS9VBZNJM", "name": "Example Coffee" }
],
"menu": { // Square merchants only
"items": [
{
"id": "ITEM_ABC",
"name": "House Latte",
"description": "Our signature espresso drink",
"category": "Beverages",
"price": "$4.50",
"priceCents": 450,
"variationId": "VAR_123" // Use this when ordering
},
{
"id": "ITEM_DEF",
"name": "Dark Roast",
"category": "Single Origin Coffee",
"price": "$18.00",
"priceCents": 1800,
"variationId": "VAR_456"
}
],
"categories": ["Beverages", "Single Origin Coffee"],
"itemCount": 42
},
"enrollment_url": "https://portald.ai/enroll/merchant_xyz"
}💡 Always check the Discovery API first. It tells you everything: whether the merchant is enabled, what payment processor they use, their full menu with prices, and where to send users for card enrollment.
This is the pattern for ordering from Square merchants. Discover first, then order from the real menu:
// Step 1: Discover merchant and get menu
const discovery = await fetch('https://www.portald.ai/api/discover?domain=merchant.com');
const { menu, enabled } = await discovery.json();
if (!enabled) throw new Error('Merchant not on Portald');
// Step 2: Find item to order
const latte = menu.items.find(i => i.name === 'French Latte');
// Step 3: Create order with real item data
const order = await fetch('https://www.portald.ai/api/agent-actions/ingest', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action_type: 'payment.charge',
action_payload: {
amount_cents: latte.priceCents,
description: latte.name,
line_items: [{
name: latte.name,
quantity: 1,
unit_price_cents: latte.priceCents,
variation_id: latte.variationId, // Required for Square orders
}],
},
website_origin: 'https://merchant.com',
idempotency_key: `order-${Date.now()}`,
}),
});
const result = await order.json();
// result.status: "pending" | "approved" | "executed"
// result.action_id: track this for status updatesWhy this pattern?
• The Discovery API returns the merchant's real Square catalog with variationId
• Using variationId ensures orders appear correctly on the merchant's POS
• Always use www.portald.ai (redirect from portald.ai strips auth headers)
| Parameter | Description |
|---|---|
domain | Domain name (e.g., shop.com) |
origin | Full origin URL (e.g., https://shop.com) |
include_menu | Include catalog items (default: true) |
menu_limit | Max menu items to return (default: 50, max: 100) |
Many Portald merchants use Shopify. Here's how to buy from them:
// 1. Get products from Shopify's built-in JSON feed
const products = await fetch("https://store.myshopify.com/products.json")
.then(r => r.json());
// products.products[] contains:
// - id: product ID
// - title: "Dark Roast Coffee"
// - variants[]: array of purchasable variants
// - id: variant ID (THIS IS WHAT YOU NEED)
// - title: "1lb Bag"
// - price: "19.99"
// - available: true
// 2. Find the product you want
const product = products.products.find(p =>
p.title.toLowerCase().includes("dark roast")
);
const variant = product.variants[0]; // or let user pick
// 3. Submit purchase to Portald
const result = await fetch("https://www.portald.ai/api/agent-actions/ingest", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${agent_session_token}`
},
body: JSON.stringify({
action_type: "purchase",
action_payload: {
product_id: product.id,
variant_id: variant.id,
product_title: product.title,
variant_title: variant.title,
quantity: 1
},
amount_cents: Math.round(parseFloat(variant.price) * 100),
website_origin: "https://store.myshopify.com",
callback_url: "https://store.myshopify.com/api/portald/webhook",
idempotency_key: `purchase-${variant.id}-${Date.now()}`
})
}).then(r => r.json());
// result.status: "pending" | "approved" | "executed"💡 Tip: Every Shopify store has /products.json publicly available. Use this to discover products, prices, and variant IDs before submitting purchases.
For sites with custom Portald integration:
// 1. Discover merchant capabilities
const manifest = await fetch("https://shop.com/.well-known/portald.json")
.then(r => r.json());
// 2. Get a quote from their endpoint
const quote = await fetch("https://shop.com/api/portald/actions/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [{ id: "product-123", quantity: 1 }],
shipping_address: { city: "Austin", state: "TX", postal_code: "78701" }
})
}).then(r => r.json());
// 3. Submit to Portald
const result = await fetch("https://www.portald.ai/api/agent-actions/ingest", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${agent_session_token}`
},
body: JSON.stringify({
action_type: "checkout.create",
action_payload: {
order_id: quote.order_id,
amount_cents: quote.total_cents
},
website_origin: "https://shop.com",
idempotency_key: `order-${quote.order_id}`
})
}).then(r => r.json());Use the Discovery API (recommended):
// Best method: Use the Discovery API
const { enabled, menu } = await fetch(
"https://portald.ai/api/discover?domain=shop.com"
).then(r => r.json());
if (enabled) {
// Store accepts Portald payments!
// menu.items contains products/prices if available
}
// Fallback: Check for manifest (custom integrations)
const hasManifest = await fetch("https://shop.com/.well-known/portald.json")
.then(r => r.ok)
.catch(() => false);
// Fallback: Check for meta tags (Shopify/hosted platforms)
const html = await fetch("https://shop.com").then(r => r.text());
const hasMetaTag = html.includes('portald:site-id') ||
html.includes('name="portald"');Agents need user approval before making purchases. See the agent enrollment guide.
| Endpoint | Method | Description |
|---|---|---|
/api/discover?domain=shop.com | GET | Check if site is Portald-enabled, get menu/catalog |
| Endpoint | Method | Description |
|---|---|---|
/api/portald/v1/identity/handshake | POST | Agent authentication |
/api/agent-actions/ingest | POST | Submit payment request |
/api/agent-actions/[id] | GET | Check action status |
/api/agent-actions/[id]/payment-info | GET | Get payment details for execution |