# Payments

> Take one-time payments and subscriptions from the first call. Vibiz is the merchant; the owner verifies their identity only to withdraw.

A project's app takes card payments, one-time and recurring, **the moment the project exists**. Vibiz is the merchant: there is no payment account to open, no identity check before selling and no processor keys in the app. Each payment lands in the project's balance in the Vibiz ledger. When the owner wants the money, they verify who they are once, add a bank account, and withdraw.

```text
 buyer ──card──▶ Vibiz (merchant, statement "VIBIZ, INC* <APP>")
                    │
                    ▼
       project balance   pending ── 7 days ──▶ available
                    │
                    │  owner: Get paid (identity + bank, once), then Withdraw
                    ▼
               the owner's bank
```

## Payments: how money moves

Vibiz is the merchant. A project's app can take payments (one-time and subscriptions) from its first call, in test mode and live, with no identity check (KYC) and no Stripe account of the owner's: identity and a bank are asked for only when the owner withdraws. Alternatively the owner can connect their own Stripe account and be paid out by it directly (below). The money is the project's balance until the owner withdraws it.

### Environments

Every `vibiz_payments` call takes `env`.

- **development** = test mode: test cards (`4242 4242 4242 4242`, any future date, any CVC), no real money, no hold. Agents may do everything here, including `simulate`.
- **production** = live. Agents may read (customer emails masked) and create products, prices and checkouts. Refunds, cancels, withdrawals and webhook/statement settings are the **owner's**: an agent key gets `403 owner_only`, because an agent key never moves live money. The owner does them in the console (project ▸ Payments) or with an owner key.

### Fees

Also on `vibiz_payments get` → `fees`, which is the source of truth.

- Card processing: **2.9% + 30¢** for most US cards, taken from each payment.
- Vibiz fee: **2%** by default, `fees.platform_bps` in basis points (200 = 2%; 0 = none). It is set per account (an admin can change it), so read it rather than assume it; on the owner's own Stripe account it is `fees.own_account_bps` (also 200 by default).
- On $100 with the defaults: $3.20 processing + $2.00 Vibiz fee, **$94.80** to the balance. `fees.example` has the numbers for this account.
- Refunds don't return the processing fee or the Vibiz fee. A dispute takes the amount plus a **$15** fee.

### When the money can be withdrawn

Live payments are **pending for `fees.hold_days` (7) days**, then available (`balance.next_available_at` says when). Test mode has no hold.

### Getting paid out

The **owner** verifies their identity and adds a bank account once (console ▸ Payments ▸ Get paid; `payouts.status` goes `not_started` → `needs_info`/`verifying` → `ready`). Then Withdraw (console, or `vibiz payments withdraw` with an owner key) sends the available balance to the bank; the payout account pays out on its own schedule. Vibiz does not promise an arrival date: `withdrawal.estimated_arrival` shows one once the bank payout is scheduled. A development withdrawal only moves test money.

### The owner's own Stripe account

Payments through Vibiz are the default (`vibiz_payments get` → `account.mode: "vibiz"`). Send the owner the own-account link **only when it fits**: they say they already have a Stripe account, or want sales paid out by their own Stripe account. Then:

1. `vibiz_payments { action: "connect_own_account", project_id, env: "production" }` (or `vibiz payments connect`) → `url`.
2. Give the owner the `url`. They sign in to the console, read what changes and choose: connect their Stripe account, or keep payments through Vibiz. You can't complete it, and only they can disconnect it.
3. Afterwards `get` shows `account.mode: "own"`. New sales are charged on their account and paid out by it: no hold and no Withdraw for them. Vibiz keeps `fees.own_account_bps` there. Money already in the Vibiz balance stays there and is withdrawn as before. The app's code (checkout, `hasActive`, webhooks) doesn't change.

Don't push it: the default needs nothing from the owner until they withdraw. `account.own.available: false` means it isn't offered right now.

### Never take card numbers

A buyer pays on the hosted checkout page. To charge someone, create a checkout (`lookup_key`/`price_id`, or `amount_cents` + `name`) and send them its `url`. Never pass a card number to a tool or an API.

### Agent flow for a plan

1. `vibiz_payments create_product { name: "Pro", price: { unit_amount_cents: 1500, type: "recurring", interval: "month", lookup_key: "pro-monthly" } }` with `env: "development"` first; the same again with `env: "production"` to go live: catalogs are separate per environment.
2. The app creates checkouts with `@vibiz/app` (`vibiz_docs app-payments`).
3. `vibiz_payments simulate payment` (development) runs a real test charge through the ledger and the app's webhooks.
4. Webhooks: `vibiz_payments webhook` (`vibiz_docs webhooks`).

## Pricing

| What | Amount | Source |
| --- | --- | --- |
| Card processing | 2.9% + 30¢ per payment, most US cards | taken from each payment; the exact fee is the processor's and shows on the payment as `fee_cents` |
| Vibiz fee | 2% of each payment by default, set per account (`fees.platform_bps`; 200 = 2%) | the account's `fees`; on the owner's own Stripe account `fees.own_account_bps` |
| Dispute | the disputed amount + $15 | taken from the balance when the dispute opens; returned if it is won |
| Refund | the refunded amount; the processing fee and the Vibiz fee are not returned | |
| Withdrawing | no Vibiz fee | |

`GET /v1/projects/:id/payments` returns the numbers the API uses right now, with a worked example:

```json
"fees": {
  "platform_bps": 200,
  "own_account_bps": 200,
  "card_fee_bps": 290,
  "card_fee_fixed_cents": 30,
  "dispute_fee_cents": 1500,
  "hold_days": 7,
  "example": { "amount_cents": 10000, "card_fee_cents": 320, "platform_fee_cents": 200, "net_cents": 9480 }
}
```

## Limits

- Currency: USD.
- Hold: live payments are pending for 7 days (`hold_days`), then available. Development has no hold.
- A refund can't exceed what is left of the payment, and is refused with `insufficient_balance` when the balance (available + pending, plus this payment's own fees) can't cover it. A project's first sale can always be refunded in full.
- A negative balance (refunds or disputes above earnings) blocks withdrawals until it is earned back.
- Checkout `metadata`: at most 20 keys, echoed on the payment and its webhooks. Checkout pages expire after 1 hour and are for one buyer.
- Statement suffix: 1–10 characters (A–Z, 0–9, space, `.`, `-`), at least one letter; buyers see `VIBIZ, INC* <SUFFIX>`.
- Payout countries: US, Canada, UK, the EEA and Switzerland. Others show as "not yet".
- A project holding live money (available or pending), with a withdrawal in flight or with a subscription still charging can't be deleted (`409`, `reason: payments_held`).

## Sell a plan, end to end

With the CLI in a linked project (development first):

```bash
vibiz payments products add Pro --amount 1500 --interval month --lookup-key pro-monthly --dev
vibiz payments checkout pro-monthly --success-url http://localhost:3000/thanks --cancel-url http://localhost:3000/pricing --dev
vibiz payments simulate payment --dev
vibiz payments --dev
```

Over HTTP with an account key (`vz_sk_…`):

```bash
curl https://app.vibiz.app/v1/projects/$PROJECT/payments/products?env=development \
  -H "Authorization: Bearer $VIBIZ_API_KEY" -H 'content-type: application/json' \
  -d '{"name":"Pro","price":{"unit_amount_cents":1500,"type":"recurring","interval":"month","lookup_key":"pro-monthly"}}'

curl https://app.vibiz.app/v1/projects/$PROJECT/payments/checkout?env=development \
  -H "Authorization: Bearer $VIBIZ_API_KEY" -H 'content-type: application/json' \
  -d '{"lookup_key":"pro-monthly","user":"u_123","success_url":"http://localhost:3000/thanks","cancel_url":"http://localhost:3000/pricing"}'
# → { "object": "checkout", "id": "…", "url": "https://…", "expires_at": "…" }
```

Catalogs are per environment: create the product again with `env=production` to sell it live.

## Payments in the app (@vibiz/app)

The app talks to Vibiz with one server secret, `VIBIZ_APP_KEY` (already in the env on Vibiz hosting and connected hosts; the development env from `vibiz_dev_env` has its own). Server-side only: never in client components or `NEXT_PUBLIC_` variables.

```ts
import { vibiz } from '@vibiz/app'; // reads VIBIZ_APP_KEY and VIBIZ_API_URL from the env
```

Install it with `npm install @vibiz/app`. A project that can't add a dependency calls the same HTTP routes with `fetch` (below), which is all it does.

### Checkout

An Upgrade button → a server action or route handler → redirect.

```ts
const { url } = await vibiz.payments.checkout({
  lookup_key: 'pro-monthly', // or price_id: 'pprice_…', or amount_cents + name for a one-off
  user: session.user.id, // your own user id: subscriptions and payments are found by it
  success_url: `${origin}/billing/success`,
  cancel_url: `${origin}/pricing`, // https; http://localhost is fine in development
  // customer_email?, quantity?, metadata? (≤ 20 keys, echoed on the payment and webhooks)
});
redirect(url); // the hosted page is for one buyer and expires after 1 hour
```

Next.js server action:

```ts
'use server';
import { redirect } from 'next/navigation';
import { vibiz } from '@vibiz/app';

export async function upgrade() {
  const user = await currentUser(); // your sign-in
  const { url } = await vibiz.payments.checkout({
    lookup_key: 'pro-monthly',
    user: user.id,
    success_url: 'https://app.example.com/billing/success',
    cancel_url: 'https://app.example.com/pricing',
  });
  redirect(url);
}
// <form action={upgrade}><button>Upgrade</button></form>
```

Create the checkout on every click: never hard-code a checkout URL, it is for one buyer and expires.

### Is this user subscribed?

```ts
await vibiz.payments.hasActive(userId, 'pro-monthly'); // boolean; price id or lookup key, or omit for any plan
await vibiz.payments.subscription(userId); // { id, status, price_id, current_period_end, cancel_at_period_end } | null
```

Manage or cancel: `const { url } = await vibiz.payments.portal(userId, { return_url });`
Catalog: `await vibiz.payments.products()`. What sold: `await vibiz.payments.payments({ user })`.
Refunds and withdrawals are not the app's (the app key is refused): they are the owner's.

### The same over HTTP

`Authorization: Bearer $VIBIZ_APP_KEY`, base `$VIBIZ_API_URL` (default `https://app.vibiz.app`).

```text
POST /v1/app/payments/checkout     { lookup_key | price_id | amount_cents+name, user, success_url, cancel_url } → { url }
GET  /v1/app/payments/subscriptions?user=u_123&status=active,trialing   → { data: [...] }
POST /v1/app/payments/portal       { user, return_url } → { url }
GET  /v1/app/payments/products
GET  /v1/app/payments/payments?user=
```

```ts
const res = await fetch(`${process.env.VIBIZ_API_URL ?? 'https://app.vibiz.app'}/v1/app/payments/checkout`, {
  method: 'POST',
  headers: {
    authorization: `Bearer ${process.env.VIBIZ_APP_KEY}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({ lookup_key: 'pro-monthly', user: user.id, success_url, cancel_url }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const { url } = await res.json();
```

A key works in one environment: the development key sees test mode, the production key live.

## Webhooks: Vibiz tells the app when someone pays

### 1. Hook it up

`vibiz_payments { action: "webhook", project_id, webhook_url: "http://localhost:3000/api/vibiz/webhook" }` (env development), or `vibiz payments webhook <url> --dev`. `http://localhost` works in development when the API runs on the same machine; otherwise use a public https URL such as the deployed app. Production needs nothing: the live webhook is `https://<the app's site>/api/vibiz/webhook` from the start (`<slug>.vibiz.app` on Vibiz hosting, or the app's own domain on another host once it's on the project), so put the route at `/api/vibiz/webhook`. Pointing it anywhere else is the owner's (console ▸ Payments ▸ Webhooks).

### 2. The secret: nothing to add

Setting a URL creates a signing secret, and the app needs nothing more than `VIBIZ_APP_KEY` to use it: `vibiz.webhooks.receive(req)` fetches it with the app key (and again after a rotation). It is also in the app's env as `VIBIZ_WEBHOOK_SECRET` for anyone verifying by hand, and never shown to agents. Locally, `vibiz dev` (or `vibiz_dev_env { project_id, write_to: "<app>/.env.development.local" }`) writes the development app key.

### 3. The route verifies the RAW body

Never re-serialized JSON.

```ts
// app/api/vibiz/webhook/route.ts
import { vibiz } from '@vibiz/app';

export async function POST(req: Request) {
  let event;
  try {
    event = await vibiz.webhooks.receive(req); // raw body + signature; needs only VIBIZ_APP_KEY
  } catch {
    return new Response('bad signature', { status: 400 });
  }
  switch (event.type) {
    case 'payment.succeeded': /* event.data.object: Payment { id, amount_cents, user, metadata, … } */ break;
    case 'subscription.created': case 'subscription.updated': case 'subscription.canceled': break;
    case 'invoice.paid': case 'invoice.payment_failed': break;
  }
  return new Response('ok');
}
```

Already read the body (Express with `express.raw`, a framework that hands you a string)? `vibiz.webhooks.verify(rawBody, header)` is the synchronous check with `VIBIZ_WEBHOOK_SECRET`. Header: `Vibiz-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the secret>`; 5 minute tolerance; several `v1` may be present. Without `@vibiz/app`:

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(raw: string, header: string | null, secret = process.env.VIBIZ_WEBHOOK_SECRET!) {
  const parts = (header ?? '').split(',').map((p) => p.trim().split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) throw new Error('stale or missing signature');
  const want = Buffer.from(createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex'));
  if (!sigs.some((v) => v.length === want.length && timingSafeEqual(Buffer.from(v), want)))
    throw new Error('bad signature');
  return JSON.parse(raw);
}
```

### Events

Event: `{ id: "evt_vz_…", type, created, environment, data: { object } }`. Types: `payment.succeeded`, `payment.refunded`, `payment.failed`, `subscription.created`, `subscription.updated`, `subscription.canceled`, `invoice.paid`, `invoice.payment_failed`, `dispute.created`, `dispute.closed`, `withdrawal.paid`, `withdrawal.failed`.

Answer 2xx fast. Non-2xx or a 10 s timeout is retried with backoff (1 m, 5 m, 30 m, 2 h, 6 h, then every 12 h) for 3 days, so the same event can arrive twice: **dedupe on `event.id`**.

### Test it

`vibiz_payments simulate payment` (development) sends a real `payment.succeeded`; `vibiz_payments deliveries` shows recent attempts and their status codes.

## Withdrawing

1. **Get paid, once.** The owner opens the console ▸ project ▸ Payments ▸ Get paid, verifies their identity and adds a bank account in the embedded form. Vibiz never sees the ID number or the bank number. `payouts.status` goes `not_started` → `needs_info` / `verifying` → `ready`; `payouts.requirements` lists what is still needed in plain words.
2. **Withdraw.** Console ▸ Payments ▸ Withdraw, or `vibiz payments withdraw [--amount <cents>]` with an owner key. The default is everything available. The withdrawal moves `pending` → `in_transit` → `paid` (or `failed`, with `failure_message`), and sends `withdrawal.paid` / `withdrawal.failed` webhooks.

In development, any key may run the whole flow with test values (date of birth 1901-01-01, SSN last 4 `0000`, routing `110000000`, account `000123456789`); nothing reaches a real bank.

## Your own Stripe account (optional)

Payments through Vibiz are the default, and most projects never change it. An owner who already has a Stripe account (or wants their sales paid out by it) can connect it instead, per project and per environment (`production` = their live account, `development` = a test-mode one):

- New sales are charged on **their** Stripe account and paid out by it, on its schedule: no Vibiz balance, no hold, no Withdraw for them ("Paid out by your Stripe account").
- Vibiz keeps its fee there (`fees.own_account_bps`; none when 0) as an application fee on each sale. Card fees are their account's.
- Refunds, cancels, subscriptions, the customer portal and webhooks work the same, on their account. Disputes are answered in their Stripe dashboard.
- Switching is for **new sales only**: money already in the Vibiz balance stays there and is withdrawn as before, and subscriptions sold through Vibiz keep billing through Vibiz.
- Disconnecting (the owner, in Payments, or from their Stripe dashboard) goes back to payments through Vibiz.

`GET /v1/projects/:id/payments` says which one a project uses:

```json
"account": {
  "mode": "own",
  "own": { "available": true, "connected": true, "account_name": "Bakery LLC", "livemode": true, "connected_at": "2026-10-28T12:00:00Z", "disconnected_at": null, "disconnect_reason": null },
  "note": "Sales are charged on the owner's own Stripe account (Bakery LLC) and paid out by it. …"
}
```

Only the owner can connect it. An agent (or anyone with a key) makes a link and gives it to them:

```bash
vibiz payments connect            # production; --dev for a test-mode account
curl -X POST "https://app.vibiz.app/v1/projects/$PROJECT/payments/own-account/link?env=production" \
  -H "Authorization: Bearer $VIBIZ_API_KEY"
# → { "object": "own_account_link", "url": "https://app.vibiz.app/connect/payments/…", "expires_at": "…", "console_url": "…" }
```

The link opens a console page for the signed-in owner: what connecting means, **Connect your Stripe account** (Stripe's own sign-in) or **Keep using Vibiz payments**. It lasts 24 hours. Disconnect: `DELETE /v1/projects/:id/payments/own-account` with an owner key, or the console.

## What agents can and can't do

| | development (test mode) | production (live) |
| --- | --- | --- |
| Read the account, payments, subscriptions, ledger | yes | yes, customer emails masked, bank hidden |
| Create products, prices, checkouts | yes | yes |
| Refund, cancel a subscription, withdraw | yes (test money) | no: `403 owner_only` |
| Webhook URL and secret, statement suffix, archive a product | yes | no: `403 owner_only` |
| Simulate a payment, dispute, refund, release the hold | yes | not available |
| Take a card number | never | never: send a checkout link |
| Connect the owner's own Stripe account | make the link, never complete it | make the link, never complete it |
| Disconnect it | no: `403 owner_only` | no: `403 owner_only` |

An `owner_only` error carries `details.next_step` with the console step to pass on (`Payments ▸ the payment ▸ Refund`, `Payments ▸ Withdraw`…). Don't retry it with the same key.

## Errors

| Code | Status | When | Next step |
| --- | --- | --- | --- |
| `forbidden` + `reason: owner_only` | 403 | an agent key on a live refund, cancel, withdrawal or setting | tell the owner `details.next_step` |
| `conflict` + `reason: payouts_not_ready` | 409 | withdraw before Get paid is done | the owner finishes Get paid in the console |
| `conflict` + `reason: nothing_available` | 409 | nothing past the hold yet | wait for `balance.next_available_at` |
| `conflict` + `reason: negative_balance` | 409 | refunds or disputes exceeded earnings | earn it back; then withdraw |
| `conflict` + `reason: withdrawal_in_progress` | 409 | another withdrawal is being created | try again shortly |
| `conflict` + `reason: disputed` | 409 | refunding a payment with an open or lost dispute | nothing: the dispute already took the money back |
| `conflict` + `reason: frozen` | 409 | Vibiz paused the project's payments | contact support |
| `insufficient_balance` | 409 | the balance can't cover the refund | wait for more sales, or refund less |
| `conflict` + `reason: own_account_disconnected` | 409 | refunding or canceling something sold on an own Stripe account that is no longer connected | the owner does it in that Stripe dashboard |
| `provider_unavailable` + `reason: own_account_unavailable` | 503 | connecting an own Stripe account isn't available in this environment | keep payments through Vibiz |
| `invalid_request` | 400 | a bad field (`details` names it), `simulate` on production | fix the request |
| `not_found` | 404 | no such product, price, payment or subscription in this environment | check `env` |
| `provider_unavailable` | 503 | payments are not available in this environment right now | retry later |

## API

All under `/v1/projects/:id/payments`, with `?env=production|development` (default: production for owner keys, development for agent keys). The full list is in the [API reference](https://app.vibiz.app/docs/api#payments); the app's own routes are under `/v1/app/payments`.
