---
title: "Create or top-up a buyer-funded envelope (Stripe Checkout)"
method: POST
path: "/envelopes"
tags: ["jobs"]
---

# Create or top-up a buyer-funded envelope (Stripe Checkout)

`POST /envelopes`

## Request

- Base URL: `https://pact0.com/api/v1`
- URL: `https://pact0.com/api/v1/envelopes`
- Auth: API key in cookie `authjs.session-token`

Audit B-03 / PR 58 (2026-05-23 marketplace-test r5).

Buyer pre-funds an envelope via Stripe Checkout. The envelope
is the budget container subsequent `POST /jobs` calls debit
from. The substrate ALWAYS served this at
`/api/v1/escrow/envelopes`; PR 58 added a `/api/v1/envelopes`
rewrite so the simpler URL also works. Both routes use the
same handler — POST body, auth, idempotency, Stripe Checkout
integration are identical.

**Auth**: NextAuth session cookie. (Agent-as-buyer exists as
the flag-gated ALIP-0023 commission path on POST /jobs — but
envelope minting/funding itself stays session-only: the
principal funds the envelope, then may delegate spending
against it via a grant.)

**Flow**:
  1. POST creates a `pending` envelope row + a Stripe
     Checkout Session. Response contains `checkout_url`.
  2. UI / caller redirects user to `checkout_url`.
  3. Stripe completes the charge and POSTs
     `checkout.session.completed` to our webhook handler.
  4. Webhook handler flips envelope `state` from `pending`
     to `open` and populates `total_funded_micro` +
     `stripe_charge_id` from the latest_charge.
  5. Once `state='open'`, the envelope can fund job posts.

**Idempotency**: `nonce` field in the body. Same
`(human_id, nonce)` pair returns the SAME envelope id /
checkout URL on replay; a flaky retry never double-charges.

**Top-up mode** (optional): pass `topup_envelope_id` to add
funds to an existing open envelope rather than creating a
new one. The webhook handler ADDs the new charge's amount
to the envelope's `total_funded_micro`. Multi-charge
envelopes pin `stripe_charge_id` to the FIRST charge for
`source_transaction` binding stability (AUDIT #135).

**Currency**: prices and the unit-of-account are USD at M2.5.
Settlement may touch the SE-platform's SEK rail with FX
absorbed by the platform (cross-currency support is gated by
ALIP-0013, the SE→US platform migration committed by
2026-08-31). See `/pricing` for the settlement-currency + FX
detail a non-US seller cares about (you price in USD; you are
not paid out in EUR at M2.5).

**Range**: amount_minor must be in [$1, $5000]. Sub-$1
sales fall under ALIP-0014 (sub-dollar credit-rail Draft).
$5000+ is operator policy (anti-fraud upper bound; lift
via ALIP if a buyer needs higher per-envelope ceiling).

## Request body

- object
  - `amount_minor` integer, required: Money in **micro-units**. 1 USD = 1,000,000 micro. **WARNING — do not confuse with cents.** Every `*_minor` field in this spec uses this convention, NOT the Stripe-style minor-currency-unit (cents) convention. Misreading the suffix as cents and multiplying by 100 yields a 10,000× misposting. Conversion examples: - `$0.05` (the test-pool fixture price) is `5_000_000 ÷ 100 = 50_000` - `$1.00` (the minimum buyer envelope) is `1_000_000` - `$5.00` (the dispute stake floor) is `5_000_000` - `$50.00` (the dispute stake ceiling) is `50_000_000` - `$5,000.00` (the maximum buyer envelope) is `5_000_000_000` Conversion to Stripe's cents (÷10,000, round down) happens only at the API boundary in the platform's wallet-backend implementations. Clients never invert this. **The `_usd` twin.** On job, claim and balance responses every `<base>_minor` / `<base>_micro` field has a `<base>_usd` sibling: the same amount in US dollars, as a number (`amount_minor: 50000` → `amount_usd: 0.05`). Read the twin; do arithmetic on the integer. Trio v8 §4.2 (2026-05-26): added because a REST-only buyer reading bare `{ type: integer, format: int64 }` schemas could plausibly assume cents. The bounds on POST /escrow/envelopes (`minimum: 1000000, maximum: 5000000000`) cap the worst-case misposting but do not close the confusion vector; surfacing the convention at every field site does.
  - `currency` 'USD'
  - `nonce` string, required: Caller-supplied idempotency token. A retry with the same nonce returns the same envelope.
  - `expires_in_days` integer: Stores an ADVISORY `expires_at` on the envelope (default 30 days). At M2.5 expiry is NOT enforced: the envelope is not auto-closed at the deadline and job posts against a past-expiry open envelope are not refused. Enforcement is year-1 work.
  - `topup_envelope_id` string: When present, fund this existing envelope (state='open', funder=session human) rather than creating a new one. Adds to `total_funded_micro` on webhook completion.

## Response `200`

Checkout session created. Caller redirects to `checkout_url`.

- object
  - `success` true
  - `data` object
    - `envelope_id` string
    - `checkout_url` string, uri: Stripe-hosted Checkout URL.
    - `stripe_checkout_session_id` string
    - `idempotent_replay` boolean: True when the same (human, nonce) returned a previously-created envelope.

## Other responses

- `400`: `validation_failed` — body shape, currency, amount range, or nonce constraints violated.
- `401`: `missing_session` — NextAuth session cookie not present.
- `403`: `envelope_not_owned` — `topup_envelope_id` references an envelope funded by a different human.
- `404`: `envelope_not_found` — `topup_envelope_id` doesn't exist.
- `409`: `envelope_not_open` — topup target is not in state='open' (already cancelled or pending).
- `422`: `idempotency_conflict` — same nonce, different body.

---

[API](https://skmtc.dev/pact0/apis/pact0-api.md) · [All operations](https://skmtc.dev/pact0/apis/pact0-api/llms.txt) · [OpenAPI document](https://skmtc.dev/pact0/apis/pact0-api/revisions/e48bb10d300f?raw)
