Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
aa1ec73
feat: discount codes, subscriber credits and a trial price
PancheI Sep 14, 2026
4ed279b
feat(checkout): @agentaos/checkout — the AgentaOS checkout inline or …
PancheI Sep 24, 2026
924a471
fix(checkout): review — strict overlay options, load failures reporte…
PancheI Sep 24, 2026
528ebf5
docs(checkout): successUrl defaults to the checkout's own success URL…
PancheI Sep 25, 2026
37c035a
feat(pay): act for a managed business — { business } on the client an…
PancheI Sep 28, 2026
c8f226e
merge: feat/subscription-pricing into the one working branch (discoun…
PancheI Sep 28, 2026
a39cd6b
feat(pay): businesses — Connect for platforms; every webhook event ty…
PancheI Sep 28, 2026
ebb8cb0
feat(cli,mcp): agenta businesses, and --business on every command and…
PancheI Sep 28, 2026
163a96e
docs(skill): Platforms — businesses you manage, business on every tool
PancheI Sep 28, 2026
ea932bc
fix(pay): dispute and account.updated event data in camelCase, as web…
PancheI Sep 28, 2026
ebd43a6
fix(cli): businesses get no longer formats the share itself (API-firs…
PancheI Sep 28, 2026
900e34f
fix(mcp-remote): answer GET with 405 and reply with plain JSON — a st…
PancheI Sep 28, 2026
b4b4c63
fix(mcp-remote): pass business on — the hosted connector never acts a…
PancheI Sep 28, 2026
fb9929c
fix(cli): businesses invite --no-email and create --no-invite-email, …
PancheI Sep 28, 2026
778c052
examples(paywall-demo): a runnable paywall for logged-in users
PancheI Sep 29, 2026
e856304
chore: example addresses on the reserved example.com domain
PancheI Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agentaos-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agentaos": minor
---

Connect — `agenta businesses list | get | create | invite | revoke-invite | id-link` for the businesses you manage (your clients' businesses and your other apps), and `--business <id>` on every command to act as one of them (`agenta products create … --business <id>`). The MCP server has the same six `agenta_businesses_*` tools and an optional `business` input on every tool.
7 changes: 7 additions & 0 deletions .changeset/agentaos-subscription-pricing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agentaos": minor
---

Six new operations, on both the CLI and the MCP tools (25 in total): `discounts create|list|show|archive` for codes buyers type at checkout, and `subscriptions credit|credits` for credit against a subscriber's next invoice. `subscriptions list --code` shows who one code brought in.
`products create --trial-amount` prices a trial, so a plan can sell "9 for the first 30 days, then 29 a month"; the created plan now says what the trial costs before it renews.
The credit command mints one idempotency key per invocation and reports it under `--json`, so running it twice is two deliberate credits while a network retry inside one call is not.
5 changes: 5 additions & 0 deletions .changeset/checkout-embedded.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agentaos/checkout": minor
---

New package: the AgentaOS checkout on your own page — inline or overlay. `loadAgentaOS()` loads the checkout script once (SSR-safe), typed options and events (`checkout.loaded`, `checkout.completed`, `checkout.closed`, `checkout.error`), and `@agentaos/checkout/react` with `<AgentaOSCheckout>` and `useAgentaOSCheckout()`.
5 changes: 5 additions & 0 deletions .changeset/mcp-remote-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agentaos/mcp-remote": patch
---

The hosted connector answers GET with 405 and replies with plain JSON (a stateless server has nothing to stream), and every tool passes `business` on, so a platform acting for a managed business never runs as itself.
9 changes: 9 additions & 0 deletions .changeset/pay-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@agentaos/pay": minor
---

Connect — manage your clients' businesses and act for them, the way Stripe Connect does.
Add `businesses.list/retrieve/create/resendInvitation/revokeInvitation/createVerificationLink()`. `create` invites your client as admin and returns `inviteUrl`; pass `sendInvitationEmail: false` to send it yourself (white-label). `createVerificationLink` is the identity check link only your client can complete (Stripe's Account Links).
Act for a business with `new AgentaOS(key, { business })` or `{ business }` as the last argument of any call — sent as `AgentaOS-Account` (Stripe's `Stripe-Account`).
`WebhookEvent` now lists every event we send — adds `dispute.created`, `dispute.closed`, `account.updated` and `webhook.test` — and each carries `business`: the business it happened in.
Fix: a call answered with 204 No Content resolves instead of failing.
7 changes: 7 additions & 0 deletions .changeset/pay-subscription-pricing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@agentaos/pay": minor
---

Add `discountCodes.create/list/get/archive()`: codes a buyer types at checkout on a subscription plan, including tracking-only codes that change no price and exist to record who a referral brought in. `subscriptions.list({ discountCode })` filters by one, and every `Subscription` now carries the `discount` it was bought with.
Add `subscriptions.credit(id, params)` and `subscriptions.credits(id)`: credit against a subscriber's next invoice. `idempotencyKey` is a required parameter, not an optional one — there is no local record of a credit for the server to recognise a repeat by, so a retry without the caller's own key credits twice.
Add `trialAmount` to `paymentLinks.create()` and to `PaymentLink`, so a plan can sell a paid trial ("9 for the first 30 days, then 29 a month") instead of only a free one.
21 changes: 21 additions & 0 deletions examples/paywall-demo/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Test key from the dashboard: Settings, Developers, Generate Test Key. Server only.
AGENTAOS_API_KEY=sk_test_replace_me

# Leave unset to use https://api.agentaos.ai. Set it only to point at another API.
# AGENTAOS_API_URL=

# The plan's link ID: on the plan's page, open "Building this into your site?" and click Copy ID.
PRO_LINK_ID=
# Optional second plan (same currency and billing interval) for the upgrade button.
PRO_PLUS_LINK_ID=

# The dashboard's webhook signing secret (Settings, Developers, Webhooks, Reveal signing secret).
# For a local test with `pnpm webhook:test`, any value works: the script signs with this same value.
AGENTAOS_WEBHOOK_SECRET=whsec_replace_me

# Where the app runs. Checkout only accepts an https return address, so run locally
# on https://127.0.0.1 with the certificate from `pnpm cert`.
APP_URL=https://127.0.0.1:4567
PORT=4567
TLS_CERT=certs/cert.pem
TLS_KEY=certs/key.pem
4 changes: 4 additions & 0 deletions examples/paywall-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.env
data.json
certs/
node_modules/
92 changes: 92 additions & 0 deletions examples/paywall-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Paywall demo: a Notes app with a Pro plan

A small Express app that puts a paid plan behind a login with `@agentaos/pay`. It shows the whole integration from your server's side:

- **Upgrade**: `POST /upgrade` creates a checkout for the signed-in user, with the user's ID in `metadata`.
- **Confirm**: `GET /success` reads the checkout with `checkouts.retrieve` and unlocks Pro when it is `completed`.
- **Webhook**: `POST /webhooks` verifies the signature, unlocks Pro on the first payment, and matches renewals by the stored subscription ID.
- **Access check**: `GET /pro` opens only for a subscription that is `active` or `trialing`.
- **Invoices**: the home page lists the user's invoices with `subscriptions.invoices` and streams the invoice PDF and the receipt.
- **Manage plan**: cancel at the period end, or quote an upgrade with `previewPlanChange` and apply it with `changePlan`.

The login is fake (pick Ada or Grace) and the "database" is `data.json`. Everything else is the real API, in test mode.

| File | What it does |
|---|---|
| `src/agentaos.ts` | The SDK client and the settings from `.env` |
| `src/checkout.ts` | `POST /upgrade` and `GET /success` |
| `src/webhooks.ts` | `POST /webhooks` |
| `src/grant.ts` | Unlocks Pro once per checkout, from the success page or the webhook |
| `src/access.ts` | Reads the subscription and guards Pro routes |
| `src/invoices.ts` | Lists the user's invoices and streams the PDFs |
| `src/plan.ts` | Cancel, quote and apply an upgrade |
| `src/store.ts`, `src/session.ts`, `src/views.ts` | The fake database, the fake login, and the HTML |
| `scripts/send-test-webhook.ts` | Sends signed sample events to `/webhooks` |

## Run it

You need Node.js 20.6 or later, pnpm, and `openssl`. The demo lives in the AgentaOS monorepo and uses the SDK from `packages/pay`.

1. Install from the repository root, and build the SDK:

```bash
pnpm install
pnpm -F @agentaos/pay build
```

2. Create the plan in the dashboard: **Products**, **New product**, **Subscription**, **Monthly**. On the plan's page, open **Building this into your site?** and click **Copy ID**. For the upgrade button, create a second plan with the same currency and interval.

3. Copy the settings and fill them in:

```bash
cd examples/paywall-demo
cp .env.example .env
```

Set `AGENTAOS_API_KEY` to a test key (Settings, Developers, **Generate Test Key**), `PRO_LINK_ID` and `PRO_PLUS_LINK_ID` to the two plan IDs, and `AGENTAOS_WEBHOOK_SECRET` to the dashboard's signing secret.

4. Make a local certificate. The checkout only returns buyers to an `https` address, and it refuses `localhost`, so the demo runs on `https://127.0.0.1:4567`:

```bash
pnpm cert
```

5. Start the app and open https://127.0.0.1:4567. Your browser warns about the self-signed certificate once; continue to the site.

```bash
pnpm dev
```

6. Sign in as Ada, click **Upgrade to Pro**, and pay with the test card `4242 4242 4242 4242`, any future expiry, any CVC. You come back to `/success`, and the home page shows the plan, **Manage plan**, and **My invoices**.

A plan with a free trial charges nothing at checkout. The subscription is `trialing`, and **My invoices** stays empty until the first charge at the end of the trial. Use a plan without a trial to see an invoice at once.

## Test the webhook on your machine

AgentaOS does not deliver webhooks to a private address such as `127.0.0.1`, so a local app gets no events. The success page unlocks Pro without them. To test the webhook route, send it signed sample events:

```bash
pnpm webhook:test
```

The script signs each body the way AgentaOS does (`X-AgentaOS-Signature: t=<unix seconds>,v1=<HMAC-SHA256 of "<t>.<raw body>">`) with `AGENTAOS_WEBHOOK_SECRET`, and prints the status for each:

```text
bad signature -> 400
renewal -> 200
same renewal again -> 200
subscription.updated -> 200
```

To receive real events, expose the app through a public HTTPS tunnel, enter the tunnel's `/webhooks` URL in Settings, Developers, **Webhook URL**, and click **Send test event**.

## Take it to production

- **Login**: replace `src/session.ts` with your own authentication.
- **Database**: replace `src/store.ts` with tables. Put a unique constraint on the processed session ID, so two deliveries of the same payment cannot both grant it.
- **Webhook**: register `https://yourapp.com/webhooks` in the dashboard and set its signing secret.
- **Keys**: use your live key (`sk_live_...`) on the live server, and keep it out of the browser and out of git.
- **Test and live**: every event carries `data.livemode`. Ignore test events on the live server.
- **HTTPS**: remove `TLS_CERT` and `TLS_KEY` when a proxy terminates TLS in front of the app, and set `APP_URL` to your public address.

Outside this monorepo, replace `"@agentaos/pay": "workspace:*"` in `package.json` with the published version.
26 changes: 26 additions & 0 deletions examples/paywall-demo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "agentaos-example-paywall-demo",
"private": true,
"type": "module",
"description": "A tiny Notes app with a Pro plan: checkout, webhooks, access check, invoices, cancel and upgrade.",
"engines": {
"node": ">=20.6.0"
},
"scripts": {
"dev": "tsx watch --env-file=.env src/server.ts",
"start": "tsx --env-file=.env src/server.ts",
"cert": "mkdir -p certs && openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj /CN=127.0.0.1 -addext subjectAltName=IP:127.0.0.1 -keyout certs/key.pem -out certs/cert.pem",
"webhook:test": "tsx --env-file=.env scripts/send-test-webhook.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@agentaos/pay": "workspace:*",
"express": "^5.2.1"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^20.19.32",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
64 changes: 64 additions & 0 deletions examples/paywall-demo/scripts/send-test-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Sends signed sample events to the demo's /webhooks route, the way AgentaOS signs them:
// header `X-AgentaOS-Signature: t=<unix seconds>,v1=<HMAC-SHA256 of "<t>.<raw body>">`.
// Use it when AgentaOS cannot reach your machine. Run: pnpm webhook:test
import { createHmac, randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { request } from 'node:https';

const url = new URL('/webhooks', process.env.APP_URL);
const secret = process.env.AGENTAOS_WEBHOOK_SECRET ?? '';

function sign(body: string): string {
const t = Math.floor(Date.now() / 1000);
return `t=${t},v1=${createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')}`;
}

function post(body: string, signature: string): Promise<number> {
return new Promise((resolve, reject) => {
const req = request(
url,
{
method: 'POST',
headers: { 'content-type': 'application/json', 'x-agentaos-signature': signature },
// Local only: the demo's certificate is self-signed.
rejectUnauthorized: false,
},
(res) => resolve(res.statusCode ?? 0),
);
req.on('error', reject);
req.end(body);
});
}

// Events use the wire format: snake_case keys, as AgentaOS sends them.
const users = JSON.parse(readFileSync('data.json', 'utf8')).users as Array<{
id: string;
subscriptionId: string | null;
}>;
const subscribed = users.find((u) => u.subscriptionId);
if (!subscribed?.subscriptionId) throw new Error('Subscribe one user in the app first.');
const subscriptionId = subscribed.subscriptionId;

const renewal = JSON.stringify({
id: `evt_${randomUUID()}`,
type: 'checkout.session.completed',
business: 'test',
data: {
session_id: `test_renewal_${Date.now()}`,
amount: '29.00',
currency: 'EUR',
livemode: false,
metadata: { subscriptionId, subscriptionCycle: true },
},
});
const updated = JSON.stringify({
id: `evt_${randomUUID()}`,
type: 'subscription.updated',
business: 'test',
data: { id: subscriptionId, status: 'active', cancel_at_period_end: true, livemode: false },
});

console.log('bad signature ->', await post(renewal, 't=1,v1=00'));
console.log('renewal ->', await post(renewal, sign(renewal)));
console.log('same renewal again ->', await post(renewal, sign(renewal)));
console.log('subscription.updated ->', await post(updated, sign(updated)));
31 changes: 31 additions & 0 deletions examples/paywall-demo/src/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Subscription } from '@agentaos/pay';
import type { NextFunction, Request, Response } from 'express';
import { agentaos } from './agentaos.js';
import type { User } from './store.js';

/** Reads one subscription by ID. The list has no metadata, so the stored ID is the key. */
export async function findSubscription(id: string | null): Promise<Subscription | null> {
if (!id) return null;
for (let offset = 0; ; offset += 100) {
const page = await agentaos.subscriptions.list({ limit: 100, offset });
const found = page.items.find((s) => s.id === id);
if (found) return found;
if (!page.hasMore) return null;
}
}

export function isPro(subscription: Subscription | null): boolean {
return subscription?.status === 'active' || subscription?.status === 'trialing';
}

/** Put in front of every Pro route. Needs `requireUser` first. */
export async function requirePro(_req: Request, res: Response, next: NextFunction): Promise<void> {
const user: User = res.locals.user;
const subscription = await findSubscription(user.subscriptionId);
if (!isPro(subscription)) {
res.redirect(303, '/?locked=1');
return;
}
res.locals.subscription = subscription;
next();
}
22 changes: 22 additions & 0 deletions examples/paywall-demo/src/agentaos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AgentaOS } from '@agentaos/pay';

function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} in .env (copy .env.example to start).`);
return value;
}

export const config = {
appUrl: required('APP_URL'),
port: Number(process.env.PORT ?? 4567),
proLinkId: required('PRO_LINK_ID'),
proPlusLinkId: process.env.PRO_PLUS_LINK_ID || null,
webhookSecret: required('AGENTAOS_WEBHOOK_SECRET'),
tlsCert: process.env.TLS_CERT || null,
tlsKey: process.env.TLS_KEY || null,
};

// Server side only: the key gives full access to your account.
export const agentaos = new AgentaOS(required('AGENTAOS_API_KEY'), {
baseUrl: process.env.AGENTAOS_API_URL || undefined,
});
57 changes: 57 additions & 0 deletions examples/paywall-demo/src/checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Request, Response } from 'express';
import { agentaos, config } from './agentaos.js';
import { grantPro } from './grant.js';
import type { User } from './store.js';
import { updateUser } from './store.js';
import { successPage } from './views.js';

// POST /upgrade: one checkout per click, tied to the signed-in user.
export async function upgrade(_req: Request, res: Response): Promise<void> {
const user: User = res.locals.user;

// A second click reuses the open checkout instead of creating another one.
if (user.pendingSessionId) {
const pending = await agentaos.checkouts.retrieve(user.pendingSessionId);
if (pending.status === 'open') {
res.redirect(303, pending.checkoutUrl);
return;
}
}

const checkout = await agentaos.checkouts.create({
linkId: config.proLinkId,
buyerEmail: user.email,
metadata: { customerId: user.id },
successUrl: `${config.appUrl}/success`,
cancelUrl: `${config.appUrl}/`,
});
updateUser(user.id, { pendingSessionId: checkout.sessionId });
res.redirect(303, checkout.checkoutUrl);
}

// GET /success?sessionId=...: the buyer lands here after paying.
export async function success(req: Request, res: Response): Promise<void> {
const user: User = res.locals.user;
const sessionId = String(req.query.sessionId ?? '');
if (!sessionId) {
res.status(400).send('sessionId missing');
return;
}

// Never unlock from the redirect alone: ask the API what happened.
const checkout = await agentaos.checkouts.retrieve(sessionId);
if (checkout.metadata.customerId !== user.id) {
res.status(404).send('Not found');
return;
}

if (checkout.status === 'completed') {
const { subscriptionId } = checkout.metadata;
grantPro({
userId: user.id,
subscriptionId: typeof subscriptionId === 'string' ? subscriptionId : null,
sessionId,
});
}
res.send(successPage(user, checkout.status));
}
Loading
Loading