@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 +21 -0
- package/README.md +9 -2
- package/package.json +34 -3
- package/skills/ambasada-payments/SKILL.md +61 -0
- package/skills/ambasada-payments/recipes/astro-static-payjs.md +55 -0
- package/skills/ambasada-payments/recipes/express.md +48 -0
- package/skills/ambasada-payments/recipes/fastapi.md +68 -0
- package/skills/ambasada-payments/recipes/lovable-bolt.md +34 -0
- package/skills/ambasada-payments/recipes/nextjs.md +54 -0
- package/skills/ambasada-payments/recipes/workers.md +27 -0
- package/skills/ambasada-payments/reference/checkout.md +75 -0
- package/skills/ambasada-payments/reference/need-vs-buy.md +26 -0
- package/skills/ambasada-payments/reference/primitives.md +73 -0
- package/skills/ambasada-payments/reference/receipts.md +57 -0
- package/skills/ambasada-payments/reference/test-mode.md +32 -0
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
|
-
#
|
|
1
|
+
# ambasada-payments skill
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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`.
|