@ambasada/skills 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CENTYSO (https://centyso.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,10 @@
1
- # Temporary Holding Version
1
+ # ambasada-payments skill
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `skill/` is the skill: `SKILL.md` (frontmatter `name`, `description`) plus `reference/` and `recipes/`.
4
+ Install it in a project for Claude Code (`.claude/skills/ambasada-payments/`), Cursor or Codex
5
+ (point the agent at `SKILL.md`). `ambasada init` vendors it into every new company.
6
+
7
+ After editing, run `pnpm --filter @ambasada/skills sync` to update the template copy;
8
+ `pnpm test` fails when they differ or a link is broken.
9
+
10
+ Built by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id). MIT.
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@ambasada/skills",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "The ambasada-payments skill (SKILL.md + reference docs + recipes) for Claude Code, Cursor and Codex.",
5
+ "type": "module",
6
+ "files": [
7
+ "skills",
8
+ "LICENSE",
9
+ "README.md"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "CENTYSO (https://centyso.com)",
13
+ "homepage": "https://ambasada.id",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/ambasada/cli.git",
17
+ "directory": "packages/skills"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/ambasada/cli/issues"
21
+ },
22
+ "keywords": [
23
+ "ambasada",
24
+ "hack-a-ton",
25
+ "ton",
26
+ "payments",
27
+ "cloudflare",
28
+ "agents"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "sync": "node scripts/sync.mjs",
35
+ "test": "node scripts/sync.mjs --check"
36
+ }
6
37
  }
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: ambasada-payments
3
+ description: Add TON payments to any app for the Hack A Ton village. Use when the user wants to accept TON from people (Checkout, pay.js button, webhooks), sell an HTTP endpoint that agents pay per call (verify the x-village-receipt), buy from other companies (village.buy / village.need), use the village LLM, images, tools or files, list offerings, or test all of it offline with `ambasada dev`. Covers Next.js, Express, FastAPI or any HTTP stack, Astro/static pages with pay.js, and Lovable/Bolt.
4
+ ---
5
+
6
+ # Village payments
7
+
8
+ The Hack A Ton village has its own currency, **TON** (not the TON blockchain: a play currency held by
9
+ the village bank). Every product in the village gets paid in TON two ways:
10
+
11
+ | Who pays | Primitive | You write |
12
+ |---|---|---|
13
+ | **People** (visitors, builders) | **Checkout**: redirect to the village approve page, or a pay.js button | create a session on your server; deliver on the signed webhook |
14
+ | **Agents and other products** | **Pay**: the Gateway calls your endpoint with a signed receipt | an HTTP POST endpoint that verifies `x-village-receipt` |
15
+
16
+ API base: `https://hackaton.ambasada.id` (`AMBASADA_API_URL`). Auth: `Authorization: Bearer <server key>`
17
+ (`AMBASADA_API_KEY`, from the village app → your team → Developers). **Never put the key in browser code.**
18
+ Amounts are TON as decimal numbers. Errors are always `{ "error": { "code", "message", ... } }`.
19
+
20
+ ## Decide what the user needs
21
+
22
+ 1. *"Let people pay on my site"* → [reference/checkout.md](reference/checkout.md). Static page without a backend → [recipes/astro-static-payjs.md](recipes/astro-static-payjs.md).
23
+ 2. *"Let agents buy my service"* → [reference/receipts.md](reference/receipts.md), then list it as an offering ([reference/primitives.md](reference/primitives.md#listings)).
24
+ 3. *"My product needs something another team sells"* → [reference/need-vs-buy.md](reference/need-vs-buy.md).
25
+ 4. *"Use an LLM / generate images / search the web"* → [reference/primitives.md](reference/primitives.md#utilities) (paid in TON from the company wallet).
26
+ 5. Always set up **test mode** first → [reference/test-mode.md](reference/test-mode.md) (`ambasada dev`, no login, fake money).
27
+
28
+ Stack recipes: [Next.js](recipes/nextjs.md) · [Express](recipes/express.md) · [FastAPI / plain HTTP](recipes/fastapi.md) ·
29
+ [Astro / static + pay.js](recipes/astro-static-payjs.md) · [Lovable / Bolt / v0](recipes/lovable-bolt.md) · [Cloudflare Workers](recipes/workers.md).
30
+
31
+ ## Rules that prevent real bugs
32
+
33
+ - **Verify on the server.** A paid endpoint must verify the receipt before doing paid work; a checkout
34
+ must be confirmed by the webhook or `GET /v1/checkout/sessions/:id` (`status: "paid"`), never by the
35
+ `?session=` in the redirect URL alone. Anything unlocked purely in the browser can be unlocked by anyone.
36
+ - **Raw body.** Receipts and webhooks are signed over the exact bytes. Read the raw body before any JSON
37
+ parser (Express: `express.raw({ type: "*/*" })` on those routes).
38
+ - **Answer 2xx only when you delivered.** Any non-2xx (or output not matching the declared `output`
39
+ schema) makes the Gateway refund the buyer. Throwing is the refund path.
40
+ - **Idempotency.** Send `Idempotency-Key` on purchases and transfers; retries with the same key never charge twice.
41
+ - **One receipt, one call.** Dedupe on `call_id` (the SDK does it per process; persist it if you run several instances).
42
+ - TypeScript: use `@ambasada/sdk` (`npm i @ambasada/sdk`). Other languages: plain HTTP, shown in each recipe.
43
+
44
+ ## The SDK in 20 lines
45
+
46
+ ```ts
47
+ import { village } from "@ambasada/sdk"; // reads AMBASADA_API_KEY, AMBASADA_API_URL
48
+
49
+ // sell to agents: a fetch handler that verifies the receipt and answers JSON (402 if unpaid)
50
+ export const POST = village.sell("translate", async ({ text, to }, call) => {
51
+ return { text: await village.llm(`Translate to ${to}. Reply with the translation only.\n\n${text}`) };
52
+ });
53
+
54
+ // sell to people
55
+ const session = await village.checkout({ amount: 3, description: "Coffee for the team", success_url: "https://my.app/thanks" });
56
+ // → redirect to session.url; then in the webhook handler:
57
+ const event = await village.verifyWebhook(req); // checkout.paid
58
+
59
+ // buy
60
+ const { output } = await village.need("image.square", { prompt: "a lighthouse" }, { maxPrice: 3 });
61
+ ```
@@ -0,0 +1,55 @@
1
+ # Astro, or any static page: pay.js (no backend)
2
+
3
+ pay.js is served by the village (`https://hackaton.ambasada.id/pay.js`; `ambasada dev` serves the same script at
4
+ `http://127.0.0.1:8788/pay.js`, and it talks to whichever origin it was loaded from). It turns a button into
5
+ "Pay with TON" using a **payment link**, so the page never needs your key.
6
+
7
+ 1. Create a link once, from your terminal:
8
+
9
+ ```sh
10
+ ambasada links create --amount 3 --description "A coffee for the team" --success-url https://my.site/thanks
11
+ # → pl_… (or POST /v1/checkout/links with curl)
12
+ ```
13
+
14
+ 2. On the page (`src/pages/index.astro` or plain HTML):
15
+
16
+ ```html
17
+ <script src="https://hackaton.ambasada.id/pay.js" defer></script>
18
+
19
+ <button data-village-pay="pl_…" data-ref="table-4">Pay 3 TON</button>
20
+ <!-- data-mode="popup" keeps the person on the page (desktop); redirect works best on phones -->
21
+
22
+ <script>
23
+ // Fired on the button (bubbles) in popup mode, and on document when the person comes back from a redirect.
24
+ document.addEventListener("village:paid", (e) => {
25
+ // e.detail.session = "cs_…", e.detail.receipt = signed checkout receipt (JWT)
26
+ document.querySelector("#thanks").hidden = false;
27
+ });
28
+ document.addEventListener("village:cancelled", (e) => console.log("not paid:", e.detail.reason)); // "cancelled" | "closed"
29
+ </script>
30
+ <p id="thanks" hidden>Mulțumim!</p>
31
+ ```
32
+
33
+ How it flows: the button goes to `https://hackaton.ambasada.id/pay/pl_…` (`?ref=` from `data-ref`). After approving,
34
+ the person returns to the link's `success_url` with `?session=cs_…`; pay.js on that page reads
35
+ `GET /v1/web/checkout/cs_…` (public) and fires `village:paid`. In popup mode the approve page posts the result
36
+ to your page's origin and closes. Each click is a new payment; a double tap is not.
37
+
38
+ From code: `const { session, receipt } = await Village.pay("pl_…", { ref: "order-42", mode: "popup" })` (rejects
39
+ with `cancelled` or `closed`), and `await Village.session("cs_…")` → `{ status, amount, description, receipt, … }`.
40
+ `ambasada links create` makes a link; `POST /v1/checkout/links/<id>/deactivate` turns one off.
41
+
42
+ **Anything unlocked purely in the browser can be unlocked by anyone who reads your JavaScript.** If the
43
+ purchase matters (a download, credits), send `e.detail.receipt` to a server or edge function and verify it:
44
+
45
+ ```ts
46
+ // e.g. an Astro endpoint src/pages/api/unlock.ts (output: "server"), or a Worker
47
+ import { village } from "@ambasada/sdk";
48
+ export const POST = async ({ request }: { request: Request }) => {
49
+ const { receipt } = await request.json();
50
+ const claims = await village.verifyCheckoutReceipt(receipt); // throws if forged / for another company
51
+ return Response.json({ ok: true, amount: claims.amount });
52
+ };
53
+ ```
54
+
55
+ Or set up the `checkout.paid` webhook (see reference/checkout.md).
@@ -0,0 +1,48 @@
1
+ # Express
2
+
3
+ `npm i @ambasada/sdk express` · env: `AMBASADA_API_KEY`, `AMBASADA_WEBHOOK_SECRET` (+ `AMBASADA_API_URL=http://127.0.0.1:8788` on `ambasada dev`)
4
+
5
+ ```ts
6
+ import express from "express";
7
+ import { isVillageError, village } from "@ambasada/sdk";
8
+
9
+ const app = express();
10
+ const raw = express.raw({ type: "*/*", limit: "1mb" }); // receipts and webhooks are signed over the raw bytes
11
+
12
+ // An offering agents pay for.
13
+ app.post("/api/translate", raw, async (req, res) => {
14
+ try {
15
+ const { input, buyer, amount } = await village.verifyReceipt(req, { offering: "translate" });
16
+ const text = await village.llm(`Translate to ${input.to}. Reply with the translation only.\n\n${input.text}`);
17
+ res.json({ text }); // 2xx = you get paid; any error status = buyer refunded
18
+ } catch (e) {
19
+ if (isVillageError(e)) return res.status(e.status || 500).json(e.toJSON()); // 402 when unpaid
20
+ throw e;
21
+ }
22
+ });
23
+
24
+ // People pay.
25
+ app.post("/buy", express.json(), async (req, res) => {
26
+ const session = await village.checkout({
27
+ amount: 3,
28
+ description: "Coffee for the team",
29
+ success_url: "https://my.app/thanks?session={CHECKOUT_SESSION_ID}",
30
+ });
31
+ res.redirect(303, session.url);
32
+ });
33
+
34
+ app.post("/village-webhook", raw, async (req, res) => {
35
+ try {
36
+ const event = await village.verifyWebhook(req);
37
+ if (event.type === "checkout.paid") await fulfil(event.data.session, event.id);
38
+ res.sendStatus(200);
39
+ } catch {
40
+ res.sendStatus(400);
41
+ }
42
+ });
43
+
44
+ app.listen(3000);
45
+ ```
46
+
47
+ Don't put `app.use(express.json())` in front of the paid routes: once the body is parsed the bytes are gone
48
+ and `verifyReceipt` refuses with a `CONFIG` error. Full example: `examples/express-seller` in the village repo.
@@ -0,0 +1,68 @@
1
+ # FastAPI (or any language over plain HTTP)
2
+
3
+ `pip install fastapi uvicorn httpx pyjwt[crypto] standardwebhooks`
4
+
5
+ ```python
6
+ import hashlib, json, os, httpx, jwt
7
+ from fastapi import FastAPI, Request, HTTPException
8
+ from standardwebhooks.webhooks import Webhook
9
+
10
+ API = os.environ.get("AMBASADA_API_URL", "https://hackaton.ambasada.id")
11
+ KEY = os.environ["AMBASADA_API_KEY"]
12
+ COMPANY = os.environ["AMBASADA_COMPANY"] # your company id
13
+ HEADERS = {"Authorization": f"Bearer {KEY}"}
14
+ app = FastAPI()
15
+ _keys = {k["kid"]: jwt.PyJWK(k) for k in httpx.get(f"{API}/.well-known/jwks.json").json()["keys"]}
16
+ _seen: set[str] = set()
17
+
18
+ def verify_receipt(token: str | None, body: bytes, offering: str) -> dict:
19
+ if not token:
20
+ raise HTTPException(402, "payment required")
21
+ key = _keys.get(jwt.get_unverified_header(token).get("kid"))
22
+ if not key:
23
+ raise HTTPException(402, "unknown village key")
24
+ try:
25
+ c = jwt.decode(token, key, algorithms=["EdDSA"], issuer="village") # checks signature + exp
26
+ except jwt.PyJWTError as e:
27
+ raise HTTPException(402, f"bad receipt: {e}")
28
+ if c.get("kind") or c["seller"] != COMPANY or c["offering"] != f"{COMPANY}/{offering}":
29
+ raise HTTPException(402, "receipt is for another offering")
30
+ if hashlib.sha256(body).hexdigest() != c["body_sha256"] or c["call_id"] in _seen:
31
+ raise HTTPException(402, "receipt does not match this request")
32
+ _seen.add(c["call_id"])
33
+ return c
34
+
35
+ def llm(prompt: str) -> str:
36
+ r = httpx.post(f"{API}/v1/llm/chat", headers=HEADERS, timeout=60,
37
+ json={"model": "deepseek-v4.1-flash", "messages": [{"role": "user", "content": prompt}]})
38
+ r.raise_for_status()
39
+ return r.json()["choices"][0]["message"]["content"]
40
+
41
+ @app.post("/api/translate") # offering endpoint agents pay for
42
+ async def translate(request: Request):
43
+ body = await request.body() # raw bytes first
44
+ verify_receipt(request.headers.get("x-village-receipt"), body, "translate")
45
+ data = json.loads(body)
46
+ return {"text": llm(f"Translate to {data['to']}. Reply with the translation only.\n\n{data['text']}")}
47
+
48
+ @app.post("/buy") # people pay
49
+ def buy():
50
+ s = httpx.post(f"{API}/v1/checkout/sessions", headers=HEADERS, json={
51
+ "amount": 3, "description": "Coffee for the team",
52
+ "success_url": "https://my.app/thanks?session={CHECKOUT_SESSION_ID}"}).json()
53
+ return {"url": s["url"]}
54
+
55
+ @app.post("/village-webhook")
56
+ async def webhook(request: Request):
57
+ body = await request.body()
58
+ try:
59
+ event = Webhook(os.environ["AMBASADA_WEBHOOK_SECRET"]).verify(body, dict(request.headers))
60
+ except Exception:
61
+ raise HTTPException(400, "bad signature")
62
+ if event["type"] == "checkout.paid":
63
+ fulfil(event["data"]["session"], request.headers["webhook-id"])
64
+ return "ok"
65
+ ```
66
+
67
+ Buy from another company: `httpx.post(f"{API}/v1/pay/inkwell/poem", headers={**HEADERS, "Idempotency-Key": str(uuid4()), "x-village-max-price": "2"}, json=input)`.
68
+ Errors come back as `{"error": {"code": …}}` with the HTTP status. Go, PHP, Ruby: same calls.
@@ -0,0 +1,34 @@
1
+ # Lovable, Bolt, v0 and other no-code builders
2
+
3
+ These tools build from a prompt, so give them the docs instead of code.
4
+
5
+ **1. Static "Pay with TON" button (works everywhere, no backend):** create a payment link with
6
+ `ambasada links create …` (or in the village app → Payments), then paste this into the builder's chat:
7
+
8
+ > Add a "Pay 3 TON" button. Include `<script src="https://hackaton.ambasada.id/pay.js" defer></script>` in the
9
+ > page head and render `<button data-village-pay="pl_XXXX">Pay 3 TON</button>` (add `data-mode="popup"` to
10
+ > stay on the page). Listen for the `village:paid` DOM event on `document`; `event.detail` has `session` and
11
+ > `receipt`. When it fires, show a thank-you message; on `village:cancelled` do nothing. The page that the
12
+ > payment returns to must also load pay.js. Do not add any other payment provider.
13
+
14
+ **2. Paid features that matter (credits, downloads, a paid API) need a server.** Lovable/Bolt apps usually have
15
+ Supabase Edge Functions or similar; paste this:
16
+
17
+ > Create an edge function `village-webhook` that verifies Standard Webhooks signatures (library
18
+ > `standardwebhooks`, secret from env `AMBASADA_WEBHOOK_SECRET`) over the raw request body, and when
19
+ > `type == "checkout.paid"` marks the order `data.session.client_reference_id` as paid (dedupe on the
20
+ > `webhook-id` header). Create an edge function `buy` that POSTs to
21
+ > `https://hackaton.ambasada.id/v1/checkout/sessions` with header `Authorization: Bearer ${AMBASADA_API_KEY}`
22
+ > and JSON `{ amount, description, success_url, client_reference_id }`, then returns the `url` from the
23
+ > response for the browser to redirect to. Never expose AMBASADA_API_KEY to the browser.
24
+
25
+ Then register the webhook URL: `ambasada webhook set https://…/functions/v1/village-webhook`.
26
+
27
+ **3. Selling to agents** needs an endpoint that verifies `x-village-receipt` (reference/receipts.md). In an
28
+ edge function with npm support: `import { village } from "npm:@ambasada/sdk"` and
29
+ `Deno.serve(village.sell("translate", async (input) => ({ … })))`.
30
+
31
+ **4. MCP:** the village MCP server (`https://hackaton.ambasada.id/mcp`) works as a Lovable custom chat
32
+ connector while building; a published app calls the HTTP API with its key or uses pay.js.
33
+
34
+ If the builder supports custom knowledge files, upload this skill's `SKILL.md` and `reference/*.md`.
@@ -0,0 +1,54 @@
1
+ # Next.js (App Router)
2
+
3
+ `npm i @ambasada/sdk` · `.env.local`: `AMBASADA_API_KEY=…` (`AMBASADA_API_URL=http://127.0.0.1:8788` while on `ambasada dev`), `AMBASADA_WEBHOOK_SECRET=whsec_…`
4
+
5
+ ```ts
6
+ // app/api/translate/route.ts: an offering agents pay for (list it with endpoint https://your.app/api/translate)
7
+ import { village } from "@ambasada/sdk";
8
+ export const runtime = "nodejs";
9
+
10
+ export const POST = village.sell<{ text: string; to: string }>("translate", async ({ text, to }) => ({
11
+ text: await village.llm(`Translate to ${to}. Reply with the translation only.\n\n${text}`),
12
+ }));
13
+ ```
14
+
15
+ ```ts
16
+ // app/api/checkout/route.ts: people pay
17
+ import { village } from "@ambasada/sdk";
18
+
19
+ export async function POST(req: Request) {
20
+ const { plan } = await req.json();
21
+ const session = await village.checkout({
22
+ amount: plan === "pro" ? 10 : 3,
23
+ description: plan === "pro" ? "Pro pack" : "Starter pack",
24
+ success_url: `${new URL(req.url).origin}/thanks?session={CHECKOUT_SESSION_ID}`,
25
+ client_reference_id: crypto.randomUUID(),
26
+ });
27
+ return Response.json({ url: session.url }); // client: location.href = url
28
+ }
29
+ ```
30
+
31
+ ```ts
32
+ // app/api/village-webhook/route.ts
33
+ import { village } from "@ambasada/sdk";
34
+
35
+ export async function POST(req: Request) {
36
+ try {
37
+ const event = await village.verifyWebhook(req); // reads the raw body itself
38
+ if (event.type === "checkout.paid") await fulfil(event.data.session.client_reference_id, event.id);
39
+ return new Response("ok");
40
+ } catch {
41
+ return new Response("bad signature", { status: 400 });
42
+ }
43
+ }
44
+ ```
45
+
46
+ ```tsx
47
+ // app/thanks/page.tsx: confirm on the server, not from the URL alone
48
+ import { village } from "@ambasada/sdk";
49
+ export default async function Thanks({ searchParams }: { searchParams: Promise<{ session?: string }> }) {
50
+ const { session } = await searchParams;
51
+ const s = session ? await village.getCheckout(session) : null;
52
+ return <h1>{s?.status === "paid" ? "Thank you!" : "Payment pending…"}</h1>;
53
+ }
54
+ ```
@@ -0,0 +1,27 @@
1
+ # Cloudflare Workers
2
+
3
+ `npm i @ambasada/sdk`, `wrangler secret put AMBASADA_API_KEY` (and `AMBASADA_WEBHOOK_SECRET`). Workers have no
4
+ `process.env`, so create the client from `env`:
5
+
6
+ ```ts
7
+ import { createAmbasada } from "@ambasada/sdk";
8
+
9
+ export default {
10
+ async fetch(req: Request, env: Record<string, string>): Promise<Response> {
11
+ const village = createAmbasada({ env }); // AMBASADA_API_KEY, AMBASADA_API_URL, AMBASADA_COMPANY, AMBASADA_WEBHOOK_SECRET
12
+ const { pathname } = new URL(req.url);
13
+ if (pathname === "/api/translate") {
14
+ return village.sell("translate", async ({ text, to }) => ({ text: await village.llm(`Translate to ${to}: ${text}`) }))(req);
15
+ }
16
+ if (pathname === "/village-webhook") {
17
+ const event = await village.verifyWebhook(req);
18
+ if (event.type === "checkout.paid") { /* fulfil event.data.session */ }
19
+ return new Response("ok");
20
+ }
21
+ return new Response("not found", { status: 404 });
22
+ },
23
+ };
24
+ ```
25
+
26
+ Set `AMBASADA_COMPANY` to your company id to skip one API call per isolate for receipt checks. Receipt keys
27
+ are cached per isolate for 10 minutes. Bun, Deno and Next.js route handlers use the same fetch-style code.
@@ -0,0 +1,75 @@
1
+ # Checkout: people pay with a redirect, you deliver on the webhook
2
+
3
+ Same shape as Stripe Checkout.
4
+
5
+ 1. **Your server** creates a session (never the browser: it needs your key).
6
+ 2. Redirect the person to `session.url`. They approve with their Ambasada ID wallet on hackaton.ambasada.id.
7
+ 3. They come back to `success_url` with `?session=cs_…` (or wherever you put `{CHECKOUT_SESSION_ID}`).
8
+ 4. The village POSTs a signed `checkout.paid` webhook to your webhook URL. **Deliver there**, or after
9
+ `GET /v1/checkout/sessions/:id` says `"status": "paid"`. Never on the redirect alone.
10
+
11
+ ```ts
12
+ import { village } from "@ambasada/sdk";
13
+
14
+ // 1–2: POST /buy
15
+ export async function buy(req: Request) {
16
+ const order = await db.orders.create({ item: "post-kit" });
17
+ const session = await village.checkout(
18
+ {
19
+ amount: 12, // or { offering: "post_kit" } to charge an offering's price
20
+ description: "Launch post kit: 3 posts + images", // shown to the person AND public in the ledger: no personal data
21
+ success_url: "https://my.app/thanks?session={CHECKOUT_SESSION_ID}",
22
+ cancel_url: "https://my.app/cart",
23
+ client_reference_id: order.id, // comes back to you, never shown
24
+ },
25
+ { idempotencyKey: order.id }, // a double click returns the same session
26
+ );
27
+ return Response.redirect(session.url, 303);
28
+ }
29
+
30
+ // 4: POST /village-webhook (register once: `ambasada webhook set https://my.app/village-webhook`)
31
+ export async function webhook(req: Request) {
32
+ const event = await village.verifyWebhook(req); // AMBASADA_WEBHOOK_SECRET; throws if forged or older than 5 min
33
+ if (event.type === "checkout.paid") {
34
+ const s = event.data.session; // same shape as GET /v1/checkout/sessions/:id
35
+ await db.orders.markPaidOnce(s.client_reference_id, event.id); // dedupe on event.id: retries reuse it
36
+ }
37
+ return new Response("ok"); // 2xx within 10 s, or it is retried (1 min … 6 h)
38
+ }
39
+
40
+ // 3: GET /thanks
41
+ export async function thanks(req: Request) {
42
+ const s = await village.getCheckout(new URL(req.url).searchParams.get("session")!);
43
+ return s.status === "paid" ? deliver(s.client_reference_id) : pending();
44
+ }
45
+ ```
46
+
47
+ ## Webhook signature (any language)
48
+
49
+ [Standard Webhooks](https://www.standardwebhooks.com): headers `webhook-id`, `webhook-timestamp`,
50
+ `webhook-signature: v1,<base64>`, where the signature is HMAC-SHA256 with the bytes of the secret after
51
+ `whsec_` (base64-decoded) over `"<webhook-id>.<webhook-timestamp>.<raw body>"`. Off-the-shelf
52
+ libraries verify it: `npm i standardwebhooks`, `pip install standardwebhooks`, Go, PHP, Ruby.
53
+ Reject timestamps more than 5 minutes off.
54
+
55
+ ## Managing it
56
+
57
+ | | HTTP | SDK |
58
+ |---|---|---|
59
+ | cancel an open session | `POST /v1/checkout/sessions/:id/expire` | `village.expireCheckout(id)` |
60
+ | payment links | `POST /v1/checkout/links`, `GET /v1/checkout/links`, `POST /v1/checkout/links/:id/deactivate` | `createPaymentLink`, `listPaymentLinks`, `deactivatePaymentLink` |
61
+ | webhook URL + secret | `PUT /v1/webhook { url, rotate_secret? }` → `{ url, secret }`, `GET /v1/webhook` (+ last deliveries), `DELETE /v1/webhook` | `setWebhook`, `getWebhook`, `deleteWebhook` |
62
+ | send a test event | `POST /v1/webhook/test` (event `webhook.test`) | `village.testWebhook()` |
63
+ | resend a delivery | `POST /v1/webhook/deliveries/:id/retry` (same `webhook-id`) | `village.retryWebhookDelivery(id)` |
64
+
65
+ Events: `{ "type": "checkout.paid", "timestamp": ISO, "data": { "session": <the session> } }` and
66
+ `{ "type": "webhook.test", "data": { "company", "message" } }`.
67
+
68
+ ## Rules
69
+
70
+ - `amount` 0.1–500 TON. A person can spend at most 30 TON a day at one team.
71
+ - `metadata`: at most 20 string values. `expires_in`: 300–86400 seconds.
72
+ - No automatic refunds: refund with `village.transfer("p_…", amount, { reason })` (the buyer id is `session.buyer`).
73
+ - Sessions expire after `expires_in` seconds (default 3600).
74
+ - Full API guide: `docs/PAYMENTS.md` in the village repo. Without the SDK, `@ambasada/protocol` exports the same
75
+ `verifyWebhook(secret, headers, rawBody)` and `verifyCheckoutReceipt(token, { keys, seller, session })`.
@@ -0,0 +1,26 @@
1
+ # village.need vs village.buy
2
+
3
+ Both buy one call through the Gateway with the same money rules (hold → call → capture or refund).
4
+
5
+ | | `village.buy("inkwell/poem", input)` | `village.need("poem", input)` |
6
+ |---|---|---|
7
+ | Supplier | hardcoded in your code | chosen by **your agent** (`needs.route`), changeable without a redeploy |
8
+ | Not routed yet | n/a | best available offering by village score within `maxPrice` |
9
+ | Supplier offline / fails | error, buyer refunded | Gateway tries the agent's fallbacks, then errors; your agent gets `need.failed` |
10
+ | Deals | price of the offering | applies a deal your agent negotiated |
11
+ | Use when | you depend on one specific product | **default**: keeps your product working while the agent shops around |
12
+
13
+ ```ts
14
+ const { output, amount, offering } = await village.need("image.square", { prompt }, { maxPrice: 3 });
15
+ // offering = which supplier served it ("pixelco/square"), amount = TON charged
16
+ ```
17
+
18
+ Options for both: `maxPrice` (refuse if pricier: `PRICE_ABOVE_MAX`), `idempotencyKey` (default: random per
19
+ call, reused by the SDK's own retry), `parent` (forward depth/budget inside a paid handler), `reason`
20
+ (shown to your team if the payment needs approval), `approval` (retry after `APPROVAL_REQUIRED`).
21
+
22
+ HTTP: `POST /v1/need/:name` and `POST /v1/pay/:company/:offering`, JSON body = input, headers
23
+ `Idempotency-Key`, `x-village-max-price`, `x-village-depth`, `x-village-budget`. Response: the seller's JSON
24
+ plus `x-village-call-id`, `x-village-amount`, `x-village-receipt`.
25
+
26
+ Your agent sees `MY NEEDS` (routed to, calls, failures, cost) on every wake and can re-route.
@@ -0,0 +1,73 @@
1
+ # The payment primitives
2
+
3
+ Base `https://hackaton.ambasada.id` · `Authorization: Bearer $AMBASADA_API_KEY` · TON as decimal numbers.
4
+
5
+ | Primitive | HTTP | SDK |
6
+ |---|---|---|
7
+ | Checkout session (people) | `POST /v1/checkout/sessions` `{ amount \| offering, description, success_url, cancel_url?, client_reference_id?, metadata? }` → `{ id, url, status }` | `village.checkout(input, { idempotencyKey })` |
8
+ | Read a session | `GET /v1/checkout/sessions/:id` → `status: "open" \| "paid" \| "expired"` | `village.getCheckout(id)` |
9
+ | Payment link (static pages) | `POST /v1/checkout/links` → `{ id: "pl_…", url }` | `village.createPaymentLink(…)` |
10
+ | Webhook endpoint | `PUT /v1/webhook { url }` → `{ secret: "whsec_…" }` | `village.setWebhook(url)` / `village.verifyWebhook(req)` |
11
+ | Pay (buy one call) | `POST /v1/pay/:company/:offering` + `Idempotency-Key`, `x-village-max-price` | `village.buy("company/offering", input, { maxPrice })` |
12
+ | Need (buy what your agent routed) | `POST /v1/need/:name` | `village.need(name, input, { maxPrice })` |
13
+ | Listings | `PUT /v1/offerings/:name` `{ description, price, endpoint, input?, output?, timeout_ms?, status? }`, `GET /v1/offerings` | `village.listings.put(name, def)` / `.list()` |
14
+ | Wallet / ledger | `GET /v1/wallet` → `{ balance, held, available }`, `GET /v1/ledger?since=` | `village.wallet()`, `village.ledger(since)` |
15
+ | Transfer (refunds, payouts) | `POST /v1/transfer { to, amount, reason? }` + `Idempotency-Key` | `village.transfer(to, amount, { reason })` |
16
+ | Receipt keys | `GET /.well-known/jwks.json` (no auth) | used by `village.verifyReceipt(req)` |
17
+
18
+ ## Listings
19
+
20
+ An offering is what agents find with `market.search` and buy through the Gateway:
21
+
22
+ ```ts
23
+ await village.listings.put("translate", {
24
+ description: "Translate RO/EN/DE/FR, keeps tone.",
25
+ price: 1, // TON, 0.1–500
26
+ endpoint: "https://my.app/api/translate", // absolute; the Gateway POSTs here
27
+ input: { text: "string", to: "string" }, // checked before money moves
28
+ output: { text: "string" }, // checked before the seller is paid
29
+ });
30
+ ```
31
+
32
+ Schema field types: `string`, `text`, `number`, `integer`, `boolean`, `object`, `array`, `json`,
33
+ `image_url`, `file_url`; a trailing `?` makes a field optional. In the template, offerings live in
34
+ `product/offerings.ts` and `ambasada offerings sync` pushes them.
35
+
36
+ ## Utilities
37
+
38
+ Paid in TON from your company wallet, only on success.
39
+
40
+ ```ts
41
+ const text = await village.llm("Say hi in Romanian"); // deepseek-v4.1-flash
42
+ const res = await village.llm.chat({ model: "deepseek-v4-pro", messages }); // OpenAI-compatible
43
+ const { url } = await village.images.generate({ prompt: "a lighthouse", model: "nano_banana" });
44
+ const tools = await village.tools.list(); // curated catalog, TON prices
45
+ const { output } = await village.tools.call("web.search", { query: "Mamaia weather" });
46
+ const file = await village.files.put(bytes, { contentType: "image/png" }); // public URL for other products
47
+ ```
48
+
49
+ HTTP: `POST /v1/llm/chat` (OpenAI body), `POST /v1/images { model, prompt }` → `{ url, cost }`,
50
+ `GET /v1/tools`, `POST /v1/tools/:id` → `{ output, cost, call_id }`, `POST /v1/files`.
51
+ LLM models: `deepseek-v4.1-flash` (default, cheap), `deepseek-v4-pro`, `glm-5.3-flash`.
52
+ Images: `nano_banana` (2 TON), `gpt-image-2` (8 TON).
53
+
54
+ ## Errors
55
+
56
+ `{ "error": { "code", "message", call_id?, charged?: false, refunded?, approval_id? } }`. The SDK throws
57
+ `VillageError` with the same `code`:
58
+
59
+ | code | meaning | what to do |
60
+ |---|---|---|
61
+ | `INSUFFICIENT_FUNDS` | wallet too low | sell something, or ask the team |
62
+ | `PRICE_ABOVE_MAX` | price rose over your `maxPrice` | re-check the market |
63
+ | `OFFLINE`, `TIMEOUT`, `UPSTREAM_ERROR`, `SCHEMA_INVALID` | the seller failed; you were refunded | retry later or pick another seller |
64
+ | `APPROVAL_REQUIRED` | over your agent's automatic limit | the team approves in the app; retry with `{ approval: e.approvalId }` |
65
+ | `CAP_*` | a spending limit | wait, or the team raises it |
66
+ | `DEPTH_EXCEEDED`, `BUDGET_EXCEEDED` | purchase chain too deep / over the parent's budget | buy less downstream |
67
+ | `PROVIDER_BUDGET` | the village's LLM pool is empty | tell the organizers |
68
+
69
+ ```ts
70
+ import { isVillageError } from "@ambasada/sdk";
71
+ try { await village.buy("inkwell/poem", input, { maxPrice: 2 }); }
72
+ catch (e) { if (isVillageError(e, "PRICE_ABOVE_MAX")) { /* … */ } else throw e; }
73
+ ```
@@ -0,0 +1,57 @@
1
+ # Selling to agents: verify the receipt
2
+
3
+ An agent (or another product) buys your offering through the Gateway:
4
+
5
+ ```
6
+ buyer ── POST /v1/pay/<you>/<offering> ──► Gateway: hold buyer's TON
7
+ Gateway ── POST <your endpoint>, header x-village-receipt ──► you
8
+ you answer 2xx + JSON matching `output` ──► Gateway captures the TON to you
9
+ anything else (4xx/5xx, timeout 30 s, wrong shape) ──► buyer refunded
10
+ ```
11
+
12
+ The receipt is an Ed25519-signed JWT: `call_id, buyer, seller, offering, amount, depth, budget?,
13
+ body_sha256, exp`. Verify it **offline** with the village key from `/.well-known/jwks.json`. A valid
14
+ receipt means the money is held and becomes yours when you answer 2xx.
15
+
16
+ ## With the SDK (any fetch-style runtime)
17
+
18
+ ```ts
19
+ import { village } from "@ambasada/sdk";
20
+
21
+ export const POST = village.sell("translate", async (input, call) => {
22
+ // input: parsed JSON body; call: { call_id, buyer, amount, depth, budget, … }
23
+ return { text: await doTheWork(input) }; // → 200 JSON, you get paid
24
+ }); // unpaid / forged / replayed → 402, no work done
25
+ ```
26
+
27
+ Or by hand: `const call = await village.verifyReceipt(req, { offering: "translate" })` returns the
28
+ claims plus `call.input`, and throws `ReceiptInvalidError` (status 402, `reason`: `missing`,
29
+ `bad_signature`, `expired`, `wrong_seller`, `wrong_offering`, `body_mismatch`, `replayed`, …).
30
+ It accepts a fetch `Request` (not consumed) or a Node/Express request with a raw body.
31
+
32
+ Checks it makes: signature by a village key · not expired · issuer `village` · `seller` is your company
33
+ (`AMBASADA_COMPANY`, or looked up once from your key) · `offering` is `<you>/translate` · the SHA-256 of
34
+ the raw body equals `body_sha256` (no swapping the input) · the `call_id` was not used before (per process).
35
+
36
+ ## Buying inside a paid call (supply chains)
37
+
38
+ Forward the chain so the Gateway can enforce depth ≤ 4 and the buyer's budget:
39
+
40
+ ```ts
41
+ export const POST = village.sell("post_kit", async ({ topic }, call) => {
42
+ const img = await village.need("image.square", { prompt: topic }, { parent: call, maxPrice: 3 });
43
+ return { image_url: img.output.image_url, text: await village.llm(`Write a post about ${topic}`) };
44
+ });
45
+ ```
46
+
47
+ ## Without the SDK
48
+
49
+ Any JWT library with EdDSA (Ed25519): fetch `GET /.well-known/jwks.json`, pick the key by `kid`, verify,
50
+ then check `exp`, `iss == "village"`, `seller`, `offering`, and `sha256(raw body) == body_sha256` (hex).
51
+ See the FastAPI recipe for a complete example.
52
+
53
+ ## Checkout receipts
54
+
55
+ pay.js gives the page a *checkout* receipt (`kind: "checkout"`, `session`, `buyer`, `amount`). Verify it on
56
+ your server with `village.verifyCheckoutReceipt(token, { session })`. A checkout receipt never verifies as a
57
+ call receipt and vice versa.
@@ -0,0 +1,32 @@
1
+ # Test mode: `ambasada dev`
2
+
3
+ A local mock of the whole Payments API on `http://127.0.0.1:8788`. No login, fake money, same routes and
4
+ signatures as production, so your code does not change between test and live: only `AMBASADA_API_URL` and
5
+ `AMBASADA_API_KEY` do.
6
+
7
+ ```sh
8
+ npx ambasada dev # in your project: registers offerings.ts pointing at product.url
9
+ AMBASADA_API_URL=http://127.0.0.1:8788 AMBASADA_API_KEY=sk_test_dev npm run dev # your product
10
+ npx ambasada dev buy translate '{"text":"Bună!","to":"en"}' # a fake agent pays you 1 TON
11
+ ```
12
+
13
+ What it gives you:
14
+
15
+ - **Fake bank**: your company starts with 500 TON; `fake-agent` (key `sk_test_agent`) has 100; the fake
16
+ visitor `p_visitor` has 100. `POST /dev/mint { to?, amount }` adds more. `ambasada wallet`, `ambasada ledger` work.
17
+ - **Real Pay proxy**: holds, calls your endpoint with a signed `x-village-receipt` (keys at
18
+ `/.well-known/jwks.json`), captures on 2xx, refunds on failure, checks `input`/`output` schemas.
19
+ - **Checkout**: `village.checkout()` returns a `/pay/cs_…` page where the fake visitor approves; the signed
20
+ `checkout.paid` webhook goes to your webhook URL (secret `AMBASADA_WEBHOOK_SECRET` from `.env`).
21
+ `/pay.js` is the production script itself, so buttons, redirect and popup mode, `village:paid` /
22
+ `village:cancelled` and `GET /v1/web/checkout/:id` behave as on the real site. Webhook test, retry and
23
+ link / session management routes are there too.
24
+ - **Canned utilities**: `/v1/llm/chat` answers `[mock] <your last message>` (0.01 TON), `/v1/images` returns
25
+ a placeholder image (2 TON), `/v1/tools` has `web.search`, `web.news`, `web.extract`, `voice.speech`.
26
+ - **Needs**: `image.square` is routed to the mock supplier `mockco/square-image`; `POST /dev/route
27
+ { need, offering }` routes others.
28
+ - **Agent**: `/v1/agent/config`, `/v1/agent/steer` (canned reply), `/v1/agent/transcript`.
29
+ - **Dashboard** at `http://127.0.0.1:8788/`: wallets, your offerings with a "Buy" button, recent calls.
30
+
31
+ Any bearer key is accepted as your company except `sk_test_agent`. Going live: `ambasada login`, put your
32
+ `sk_live_…` key in the host's env, remove `AMBASADA_API_URL`, `ambasada link https://your.product`, `ambasada deploy`.