Copy an integration example
Choose your language, supply an Agent connection's credentials, and run the example. Each file calls Spendkit OAuth and MCP directly, discovers tools, reads the bound Agent and Policy, and previews a payment. Your own business logic goes at the marked comment. The same public examples can be read by an AI coding assistant.
With a Client ID and Client Secret, they connect to Spendkit and read the Agent and Policy. Add SPENDKIT_RECIPIENT to preview a proposed USDG payment. This default path does not send money. To enable payment, first implement the marked wallet sender using the Agent's bound wallet; then the example can request authorization, submit the exact transaction, record its hash, and query status.
Before you copy
- Open AI Agents in the dashboard → Create an Agent, open its details, bind and verify its Payment Wallet, and set its onchain Spending Policy.
- In that Agent's details, select Connect Agent and choose Full Runtime Access for payments. Copy the Client ID and Client Secret into your Runtime environment or protected secret store. Open Agent configuration →
- Copy one complete file below. For real payment, add your own wallet signer at
createPaymentWalletSender(or the equivalent function). Provide a stableSPENDKIT_IDEMPOTENCY_KEYfor each logical payment.
Where each value comes from
| Value in the example | Where to get or set it |
|---|---|
SPENDKIT_BASE_URL | Use https://spendkit-alpha.vercel.app for this hosted service; the examples already use it by default. It is a public URL, not a credential created in the dashboard. |
SPENDKIT_CLIENT_ID and SPENDKIT_CLIENT_SECRET | In AI Agents → open your Agent and select Connect Agent. Copy the values from its connection settings. Store the secret in protected Runtime configuration, never in source code or an AI prompt. |
| Payment Wallet address and signer | Bind and verify your own wallet in the Agent setup → You can check its bound address in the dashboard or with spendkit_get_connection. Your wallet app or secure signing service supplies the signer. If it uses a private key, keep that key in your own protected secret store. The dashboard does not generate or show a private key; never paste one there. |
SPENDKIT_RECIPIENT and SPENDKIT_AMOUNT | Supply the recipient and proposed amount from your application's payment request. They are not dashboard credentials. |
SPENDKIT_IDEMPOTENCY_KEY | Generate a stable ID in your application for one logical payment, such as an order ID; reuse it only when retrying that same payment. |
Connect → get_connection → get_policy → preview_paymentauthorize_payment → wallet send → record_payment → get_payment_statusAfter your wallet submits the authorized transaction, keep spendkit_record_payment(intentId, txHash) and spendkit_get_payment_status(intentId) in your integration. The first links the submitted transaction to its Spendkit payment request so Payment Activity can update promptly; the second checks the final onchain result. If recording fails, retry recording or query the same intent and transaction—do not send the payment again. Without recording, the dashboard may show an outdated status until onchain reconciliation catches up. The Router still enforces the onchain spending limits.
Choose a language
All four examples use the current MCP protocol and the same tool sequence. They are one-shot command-line clients: a long-running Agent should cache and refresh OAuth tokens before expiry. The tool reference explains each input and output.
Save as javascript.mjs and run node javascript.mjs. No extra package is required. Open the complete source file →
// Node.js 20+. Run with SPENDKIT_CLIENT_ID, SPENDKIT_CLIENT_SECRET,
// SPENDKIT_RECIPIENT and optionally SPENDKIT_AMOUNT in your environment.
// Default run is read-only. Payment requires SPENDKIT_ENABLE_PAYMENT=1 and
// your own implementation of createPaymentWalletSender below.
// Public base URL defaults below. Dashboard > AI Agents > your Agent > Connect Agent
// provides Client ID/Secret. Your own wallet signer supplies the payment key;
// never enter a private key in the dashboard or expose it to the AI model.
const baseUrl = (process.env.SPENDKIT_BASE_URL || "https://spendkit-alpha.vercel.app").replace(/\/$/, "");
const protocol = "2026-07-28";
const clientInfo = { name: "spendkit-javascript-example", version: "1.0.0" };
const clientId = process.env.SPENDKIT_CLIENT_ID;
const clientSecret = process.env.SPENDKIT_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new Error("Set SPENDKIT_CLIENT_ID and SPENDKIT_CLIENT_SECRET");
async function accessToken() {
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
});
const response = await fetch(`${baseUrl}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const data = await response.json();
if (!response.ok || !data.access_token) throw new Error(`OAuth failed: ${JSON.stringify(data)}`);
return data.access_token;
}
function createMcpClient(token) {
let id = 0;
async function rpc(method, params = {}) {
const name = params.name;
const body = {
jsonrpc: "2.0",
id: ++id,
method,
params: {
...params,
_meta: {
"io.modelcontextprotocol/protocolVersion": protocol,
"io.modelcontextprotocol/clientInfo": clientInfo,
"io.modelcontextprotocol/clientCapabilities": {},
},
},
};
const response = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
"MCP-Protocol-Version": protocol,
"Mcp-Method": method,
...(name ? { "Mcp-Name": name } : {}),
},
body: JSON.stringify(body),
});
const message = await response.json();
if (!response.ok || message.error) throw new Error(`MCP ${method} failed: ${JSON.stringify(message.error || message)}`);
return message.result;
}
async function tool(name, args = {}) {
const result = await rpc("tools/call", { name, arguments: args });
const data = result.structuredContent ?? JSON.parse(result.content.find((item) => item.type === "text").text);
if (result.isError && data.allowed !== false && data.accepted !== false) {
throw new Error(`${name} failed: ${data.reason || data.error || JSON.stringify(data)}`);
}
return data;
}
return { rpc, tool };
}
function createPaymentWalletSender() {
// YOUR WALLET CODE: return an async function that validates and submits the
// exact transaction using the wallet bound to this Agent. Keep the private
// key outside the AI model and Spendkit Server. Return the real tx hash.
throw new Error("Implement createPaymentWalletSender before enabling payment");
}
const token = await accessToken();
const mcp = createMcpClient(token);
await mcp.rpc("server/discover");
await mcp.rpc("tools/list");
const connection = await mcp.tool("spendkit_get_connection");
const policy = await mcp.tool("spendkit_get_policy");
console.log("Agent:", connection.agent.name, "Wallet:", connection.agent.paymentWallet?.address);
console.log("Policy ready:", policy.ready, "Remaining today:", policy.remainingDaily);
// YOUR BUSINESS CODE: replace these environment values with the payment
// amount and recipient selected by your own application or AI Agent.
const payment = {
amount: process.env.SPENDKIT_AMOUNT || "0.01",
recipient: process.env.SPENDKIT_RECIPIENT,
token: "USDG",
};
if (!payment.recipient) {
console.log("Set SPENDKIT_RECIPIENT to preview a payment");
process.exit(0);
}
const preview = await mcp.tool("spendkit_preview_payment", payment);
console.log("Preview:", preview.allowed, preview.reason);
if (!preview.allowed || process.env.SPENDKIT_ENABLE_PAYMENT !== "1") process.exit(0);
// Prepare the wallet before requesting an authorization. The default example
// stops here until you implement the wallet sender above.
const sendFromBoundWallet = createPaymentWalletSender();
const idempotencyKey = process.env.SPENDKIT_IDEMPOTENCY_KEY;
if (!idempotencyKey) throw new Error("Set one stable SPENDKIT_IDEMPOTENCY_KEY per logical payment");
const authorized = await mcp.tool("spendkit_authorize_payment", { ...payment, idempotencyKey });
if (!authorized.allowed || !authorized.transaction) throw new Error("No new Router transaction was authorized; inspect intent status");
const boundWallet = connection.agent.paymentWallet?.address?.toLowerCase();
const transaction = authorized.transaction;
if (!boundWallet || transaction.from.toLowerCase() !== boundWallet
|| authorized.authorization.paymentWallet.toLowerCase() !== boundWallet
|| authorized.authorization.recipient.toLowerCase() !== payment.recipient.toLowerCase()
|| authorized.authorization.amount !== preview.amountAtomic
|| authorized.authorization.token.toLowerCase() !== preview.policy.tokenAddress.toLowerCase()
|| transaction.to.toLowerCase() !== connection.network.routerAddress.toLowerCase()
|| transaction.chainId !== connection.network.chainId) {
throw new Error("Authorization does not match the intended payment or bound wallet");
}
const txHash = await sendFromBoundWallet(transaction);
// KEEP THIS CALL after wallet submission: intentId + txHash lets Spendkit
// track the payment promptly. Without it, Activity/status can lag until
// onchain reconciliation; the Router still enforces spending limits.
const recorded = await mcp.tool("spendkit_record_payment", { intentId: authorized.intentId, txHash });
// If recording fails, retry with this intentId/txHash; never resend the payment.
if (!recorded.accepted) throw new Error(`Record failed: ${recorded.error}`);
// KEEP THIS CALL to reconcile the receipt; submitted is not final success.
const status = await mcp.tool("spendkit_get_payment_status", { intentId: authorized.intentId });
console.log("Payment status:", status.status, "Transaction:", status.txHash);
Save as python.py and run python3 python.py. It uses only the standard library. Open the complete source file →
"""Python 3.10+ Spendkit MCP example using only the standard library.
Set SPENDKIT_CLIENT_ID, SPENDKIT_CLIENT_SECRET, and SPENDKIT_RECIPIENT.
The default run is read-only. Payment also requires your wallet adapter below.
The base URL defaults below. Dashboard > AI Agents > your Agent > Connect Agent
provides Client ID/Secret. Your own wallet signer supplies the payment key;
never enter a private key in the dashboard or expose it to the AI model.
"""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
BASE = os.getenv("SPENDKIT_BASE_URL", "https://spendkit-alpha.vercel.app").rstrip("/")
PROTOCOL = "2026-07-28"
CLIENT_INFO = {"name": "spendkit-python-example", "version": "1.0.0"}
def post_json(url, body, headers):
request = urllib.request.Request(
url, data=json.dumps(body).encode(), headers=headers, method="POST"
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.load(response)
except urllib.error.HTTPError as error:
raise RuntimeError(f"HTTP {error.code}: {error.read().decode()}") from error
def get_access_token():
client_id = os.getenv("SPENDKIT_CLIENT_ID")
client_secret = os.getenv("SPENDKIT_CLIENT_SECRET")
if not client_id or not client_secret:
raise RuntimeError("Set SPENDKIT_CLIENT_ID and SPENDKIT_CLIENT_SECRET")
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
}).encode()
request = urllib.request.Request(
f"{BASE}/oauth/token", data=form,
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST"
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.load(response)["access_token"]
except urllib.error.HTTPError as error:
raise RuntimeError(f"OAuth HTTP {error.code}: {error.read().decode()}") from error
class SpendkitMcp:
def __init__(self, token):
self.token = token
self.next_id = 0
def rpc(self, method, params=None):
self.next_id += 1
params = dict(params or {})
params["_meta"] = {
"io.modelcontextprotocol/protocolVersion": PROTOCOL,
"io.modelcontextprotocol/clientInfo": CLIENT_INFO,
"io.modelcontextprotocol/clientCapabilities": {},
}
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"Accept": "application/json",
"MCP-Protocol-Version": PROTOCOL,
"Mcp-Method": method,
}
if method == "tools/call":
headers["Mcp-Name"] = params["name"]
message = post_json(f"{BASE}/mcp", {
"jsonrpc": "2.0", "id": self.next_id,
"method": method, "params": params,
}, headers)
if "error" in message:
raise RuntimeError(f"MCP {method}: {message['error']}")
return message["result"]
def tool(self, name, arguments=None):
result = self.rpc("tools/call", {"name": name, "arguments": arguments or {}})
data = result.get("structuredContent")
if data is None:
data = json.loads(next(item["text"] for item in result["content"] if item["type"] == "text"))
if result.get("isError") and data.get("allowed") is not False and data.get("accepted") is not False:
raise RuntimeError(f"{name} failed: {data}")
return data
def create_payment_wallet_sender():
# YOUR WALLET CODE: return a function that validates and submits the exact
# Router transaction from the wallet bound to this Agent, then returns its
# real tx hash. Do not give the wallet key to the model or Spendkit Server.
raise NotImplementedError("Implement the wallet sender before enabling payment")
def main():
mcp = SpendkitMcp(get_access_token())
mcp.rpc("server/discover")
mcp.rpc("tools/list")
connection = mcp.tool("spendkit_get_connection")
policy = mcp.tool("spendkit_get_policy")
print("Agent:", connection["agent"]["name"], "Policy ready:", policy["ready"])
# YOUR BUSINESS CODE: replace these environment values with the payment
# amount and recipient selected by your application or AI Agent.
payment = {
"amount": os.getenv("SPENDKIT_AMOUNT", "0.01"),
"recipient": os.getenv("SPENDKIT_RECIPIENT", ""),
"token": "USDG",
}
if not payment["recipient"]:
print("Set SPENDKIT_RECIPIENT to preview a payment")
return
preview = mcp.tool("spendkit_preview_payment", payment)
print("Preview:", preview["allowed"], preview.get("reason"))
if not preview["allowed"] or os.getenv("SPENDKIT_ENABLE_PAYMENT") != "1":
return
# This fails before an authorization is created until your wallet code exists.
send_from_bound_wallet = create_payment_wallet_sender()
key = os.getenv("SPENDKIT_IDEMPOTENCY_KEY")
if not key:
raise RuntimeError("Set one stable SPENDKIT_IDEMPOTENCY_KEY per logical payment")
authorized = mcp.tool("spendkit_authorize_payment", {**payment, "idempotencyKey": key})
if not authorized.get("allowed") or not authorized.get("transaction"):
raise RuntimeError("No new transaction was authorized; inspect intent status")
wallet = connection["agent"]["paymentWallet"]["address"].lower()
transaction = authorized["transaction"]
if (transaction["from"].lower() != wallet
or authorized["authorization"]["paymentWallet"].lower() != wallet
or authorized["authorization"]["recipient"].lower() != payment["recipient"].lower()
or authorized["authorization"]["amount"] != preview["amountAtomic"]
or authorized["authorization"]["token"].lower() != preview["policy"]["tokenAddress"].lower()
or transaction["to"].lower() != connection["network"]["routerAddress"].lower()
or transaction["chainId"] != connection["network"]["chainId"]):
raise RuntimeError("Authorization does not match intended payment or bound wallet")
tx_hash = send_from_bound_wallet(transaction)
# KEEP THIS CALL after wallet submission: intentId + txHash lets Spendkit
# track the payment promptly. Without it, Activity/status can lag until
# onchain reconciliation; the Router still enforces spending limits.
recorded = mcp.tool("spendkit_record_payment", {
"intentId": authorized["intentId"], "txHash": tx_hash,
})
# If recording fails, retry with this intentId/txHash; never resend payment.
if not recorded["accepted"]:
raise RuntimeError(f"Record failed: {recorded.get('error')}")
# KEEP THIS CALL to reconcile the receipt; submitted is not final success.
status = mcp.tool("spendkit_get_payment_status", {"intentId": authorized["intentId"]})
print("Payment status:", status["status"], "Transaction:", status.get("txHash"))
if __name__ == "__main__":
main()
Save as go.go and run go run go.go. It uses only the standard library. Open the complete source file →
// Go 1.21+. Set SPENDKIT_CLIENT_ID, SPENDKIT_CLIENT_SECRET, SPENDKIT_RECIPIENT.
// Default run only reads connection, Policy, and a payment preview.
// Payment requires SPENDKIT_ENABLE_PAYMENT=1 and your wallet adapter below.
// The base URL defaults below. Dashboard > AI Agents > your Agent > Connect Agent
// provides Client ID/Secret. Your own wallet signer supplies the payment key;
// never enter a private key in the dashboard or expose it to the AI model.
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const protocol = "2026-07-28"
var httpClient = &http.Client{Timeout: 15 * time.Second}
func environment(name, fallback string) string {
if value := os.Getenv(name); value != "" { return value }
return fallback
}
func object(value any) map[string]any {
result, ok := value.(map[string]any)
if !ok { return map[string]any{} }
return result
}
func field(value map[string]any, key string) string {
result, _ := value[key].(string)
return result
}
func decode(response *http.Response) (map[string]any, error) {
defer response.Body.Close()
var value map[string]any
decoder := json.NewDecoder(response.Body)
decoder.UseNumber()
if err := decoder.Decode(&value); err != nil { return nil, err }
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d: %v", response.StatusCode, value)
}
return value, nil
}
func token(baseURL string) (string, error) {
id, secret := os.Getenv("SPENDKIT_CLIENT_ID"), os.Getenv("SPENDKIT_CLIENT_SECRET")
if id == "" || secret == "" { return "", errors.New("set SPENDKIT_CLIENT_ID and SPENDKIT_CLIENT_SECRET") }
form := url.Values{"grant_type": {"client_credentials"}, "client_id": {id}, "client_secret": {secret}}
request, err := http.NewRequest("POST", baseURL+"/oauth/token", strings.NewReader(form.Encode()))
if err != nil { return "", err }
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := httpClient.Do(request)
if err != nil { return "", err }
data, err := decode(response)
if err != nil { return "", err }
accessToken := field(data, "access_token")
if accessToken == "" { return "", errors.New("OAuth response has no access_token") }
return accessToken, nil
}
type MCP struct {
baseURL string
token string
id int
}
func (client *MCP) rpc(method string, params map[string]any) (map[string]any, error) {
client.id++
if params == nil { params = map[string]any{} }
params["_meta"] = map[string]any{
"io.modelcontextprotocol/protocolVersion": protocol,
"io.modelcontextprotocol/clientInfo": map[string]any{"name": "spendkit-go-example", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": map[string]any{},
}
body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": client.id, "method": method, "params": params})
if err != nil { return nil, err }
request, err := http.NewRequest("POST", client.baseURL+"/mcp", strings.NewReader(string(body)))
if err != nil { return nil, err }
request.Header.Set("Authorization", "Bearer "+client.token)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("MCP-Protocol-Version", protocol)
request.Header.Set("Mcp-Method", method)
if method == "tools/call" { request.Header.Set("Mcp-Name", field(params, "name")) }
response, err := httpClient.Do(request)
if err != nil { return nil, err }
message, err := decode(response)
if err != nil { return nil, err }
if message["error"] != nil { return nil, fmt.Errorf("MCP %s: %v", method, message["error"]) }
return object(message["result"]), nil
}
func (client *MCP) tool(name string, args map[string]any) (map[string]any, error) {
if args == nil { args = map[string]any{} }
result, err := client.rpc("tools/call", map[string]any{"name": name, "arguments": args})
if err != nil { return nil, err }
data := object(result["structuredContent"])
if len(data) == 0 {
for _, entry := range result["content"].([]any) {
content := object(entry)
if field(content, "type") == "text" {
if err := json.Unmarshal([]byte(field(content, "text")), &data); err != nil { return nil, err }
break
}
}
}
if result["isError"] == true && data["allowed"] != false && data["accepted"] != false {
return nil, fmt.Errorf("%s failed: %v", name, data)
}
return data, nil
}
type WalletSender func(transaction map[string]any) (string, error)
func createPaymentWalletSender() (WalletSender, error) {
// YOUR WALLET CODE: connect your EVM wallet signer, validate the exact
// Router transaction and submit it from the wallet bound to this Agent.
// Return the real tx hash. Keep wallet keys outside the AI model.
return nil, errors.New("implement the wallet sender before enabling payment")
}
func run() error {
baseURL := strings.TrimRight(environment("SPENDKIT_BASE_URL", "https://spendkit-alpha.vercel.app"), "/")
accessToken, err := token(baseURL)
if err != nil { return err }
mcp := &MCP{baseURL: baseURL, token: accessToken}
if _, err = mcp.rpc("server/discover", nil); err != nil { return err }
if _, err = mcp.rpc("tools/list", nil); err != nil { return err }
connection, err := mcp.tool("spendkit_get_connection", nil)
if err != nil { return err }
policy, err := mcp.tool("spendkit_get_policy", nil)
if err != nil { return err }
agent := object(connection["agent"])
fmt.Println("Agent:", field(agent, "name"), "Policy ready:", policy["ready"])
// YOUR BUSINESS CODE: replace environment values with the payment intent
// chosen by your own application or AI Agent.
payment := map[string]any{
"amount": environment("SPENDKIT_AMOUNT", "0.01"),
"recipient": os.Getenv("SPENDKIT_RECIPIENT"),
"token": "USDG",
}
if payment["recipient"] == "" { fmt.Println("Set SPENDKIT_RECIPIENT to preview a payment"); return nil }
preview, err := mcp.tool("spendkit_preview_payment", payment)
if err != nil { return err }
fmt.Println("Preview:", preview["allowed"], preview["reason"])
if preview["allowed"] != true || os.Getenv("SPENDKIT_ENABLE_PAYMENT") != "1" { return nil }
sender, err := createPaymentWalletSender() // Fails before authorization until implemented.
if err != nil { return err }
key := os.Getenv("SPENDKIT_IDEMPOTENCY_KEY")
if key == "" { return errors.New("set one stable SPENDKIT_IDEMPOTENCY_KEY per logical payment") }
arguments := map[string]any{"amount": payment["amount"], "recipient": payment["recipient"], "token": "USDG", "idempotencyKey": key}
authorized, err := mcp.tool("spendkit_authorize_payment", arguments)
if err != nil { return err }
transaction := object(authorized["transaction"])
if authorized["allowed"] != true || len(transaction) == 0 { return errors.New("no new transaction authorized; inspect intent status") }
network := object(connection["network"])
wallet := field(object(agent["paymentWallet"]), "address")
auth := object(authorized["authorization"])
if wallet == "" || !strings.EqualFold(field(transaction, "from"), wallet) ||
!strings.EqualFold(field(auth, "paymentWallet"), wallet) ||
!strings.EqualFold(field(auth, "recipient"), field(payment, "recipient")) ||
field(auth, "amount") != field(preview, "amountAtomic") ||
!strings.EqualFold(field(auth, "token"), field(object(preview["policy"]), "tokenAddress")) ||
!strings.EqualFold(field(transaction, "to"), field(network, "routerAddress")) ||
transaction["chainId"] != network["chainId"] {
return errors.New("authorization does not match intended payment or bound wallet")
}
txHash, err := sender(transaction)
if err != nil { return err }
intentID := field(authorized, "intentId")
// KEEP THIS CALL after wallet submission: intentId + txHash lets Spendkit
// track payment promptly. Without it, Activity/status can lag until onchain
// reconciliation; the Router still enforces spending limits.
recorded, err := mcp.tool("spendkit_record_payment", map[string]any{"intentId": intentID, "txHash": txHash})
if err != nil { return err }
// If recording fails, retry with this intentID/txHash; never resend payment.
if recorded["accepted"] != true { return fmt.Errorf("record failed: %v", recorded["error"]) }
// KEEP THIS CALL to reconcile the receipt; submitted is not final success.
status, err := mcp.tool("spendkit_get_payment_status", map[string]any{"intentId": intentID})
if err != nil { return err }
fmt.Println("Payment status:", status["status"], "Transaction:", status["txHash"])
return nil
}
func main() {
if err := run(); err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
}
Save as cpp.cpp. Install libcurl and the nlohmann/json.hpp header, then compile with c++ -std=c++17 cpp.cpp -lcurl -o spendkit-example. Open the complete source file →
// C++17 example. Requires libcurl and nlohmann/json.hpp.
// Set SPENDKIT_CLIENT_ID, SPENDKIT_CLIENT_SECRET, SPENDKIT_RECIPIENT.
// Default run is read-only. Implement the wallet callback before enabling pay.
// The base URL defaults below. Dashboard > AI Agents > your Agent > Connect Agent
// provides Client ID/Secret. Your own wallet signer supplies the payment key;
// never enter a private key in the dashboard or expose it to the AI model.
#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <functional>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
using json = nlohmann::json;
const std::string protocol = "2026-07-28";
std::string env(const char* key, const std::string& fallback = "") {
const char* value = std::getenv(key);
return value && *value ? std::string(value) : fallback;
}
std::string encode(const std::string& value) {
std::ostringstream out;
for (unsigned char ch : value) {
if (std::isalnum(ch) || ch == '-' || ch == '_' || ch == '.' || ch == '~') out << ch;
else out << '%' << std::uppercase << std::hex << std::setw(2)
<< std::setfill('0') << static_cast<int>(ch) << std::dec;
}
return out.str();
}
size_t appendBody(char* data, size_t size, size_t count, void* target) {
const size_t length = size * count;
static_cast<std::string*>(target)->append(data, length);
return length;
}
json post(const std::string& url, const std::string& body,
const std::vector<std::string>& headers) {
CURL* curl = curl_easy_init();
if (!curl) throw std::runtime_error("Cannot initialize libcurl");
curl_slist* list = nullptr;
for (const auto& header : headers) list = curl_slist_append(list, header.c_str());
std::string responseBody;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(body.size()));
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, appendBody);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseBody);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
const CURLcode result = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
curl_slist_free_all(list);
curl_easy_cleanup(curl);
if (result != CURLE_OK) throw std::runtime_error(curl_easy_strerror(result));
if (status < 200 || status >= 300)
throw std::runtime_error("HTTP " + std::to_string(status) + ": " + responseBody);
return json::parse(responseBody);
}
class SpendkitMcp {
public:
SpendkitMcp(std::string base, std::string token)
: base_(std::move(base)), token_(std::move(token)) {}
json rpc(const std::string& method, json params = json::object()) {
const json meta = {
{"io.modelcontextprotocol/protocolVersion", protocol},
{"io.modelcontextprotocol/clientInfo", {{"name", "spendkit-cpp-example"}, {"version", "1.0.0"}}},
{"io.modelcontextprotocol/clientCapabilities", json::object()},
};
params["_meta"] = meta;
const json request = {{"jsonrpc", "2.0"}, {"id", ++id_}, {"method", method}, {"params", params}};
std::vector<std::string> headers = {
"Authorization: Bearer " + token_, "Content-Type: application/json",
"Accept: application/json", "MCP-Protocol-Version: " + protocol,
"Mcp-Method: " + method,
};
if (method == "tools/call") headers.push_back("Mcp-Name: " + params.at("name").get<std::string>());
const json message = post(base_ + "/mcp", request.dump(), headers);
if (message.contains("error")) throw std::runtime_error("MCP " + method + ": " + message.at("error").dump());
return message.at("result");
}
json tool(const std::string& name, const json& args = json::object()) {
const json result = rpc("tools/call", {{"name", name}, {"arguments", args}});
json data = result.contains("structuredContent")
? result.at("structuredContent")
: json::parse(result.at("content").at(0).at("text").get<std::string>());
if (result.value("isError", false) && data.value("allowed", true) && data.value("accepted", true))
throw std::runtime_error(name + " failed: " + data.dump());
return data;
}
private:
std::string base_;
std::string token_;
int id_ = 0;
};
std::function<std::string(const json&)> createPaymentWalletSender() {
// YOUR WALLET CODE: return a function that verifies the exact Router
// transaction, submits it from the bound wallet, and returns its tx hash.
// Keep the private key out of the model and Spendkit Server.
throw std::runtime_error("Implement the wallet sender before enabling payment");
}
bool sameAddress(std::string left, std::string right) {
auto lower = [](unsigned char c) { return static_cast<char>(std::tolower(c)); };
std::transform(left.begin(), left.end(), left.begin(), lower);
std::transform(right.begin(), right.end(), right.begin(), lower);
return left == right;
}
int main() {
curl_global_init(CURL_GLOBAL_DEFAULT);
try {
const std::string id = env("SPENDKIT_CLIENT_ID"), secret = env("SPENDKIT_CLIENT_SECRET");
if (id.empty() || secret.empty()) throw std::runtime_error("Set SPENDKIT_CLIENT_ID and SPENDKIT_CLIENT_SECRET");
std::string base = env("SPENDKIT_BASE_URL", "https://spendkit-alpha.vercel.app");
if (!base.empty() && base.back() == '/') base.pop_back();
const std::string form = "grant_type=client_credentials&client_id=" + encode(id)
+ "&client_secret=" + encode(secret);
const json oauth = post(base + "/oauth/token", form,
{"Content-Type: application/x-www-form-urlencoded", "Accept: application/json"});
SpendkitMcp mcp(base, oauth.at("access_token").get<std::string>());
mcp.rpc("server/discover");
mcp.rpc("tools/list");
const json connection = mcp.tool("spendkit_get_connection");
const json policy = mcp.tool("spendkit_get_policy");
std::cout << "Agent: " << connection.at("agent").at("name")
<< " Policy ready: " << policy.at("ready") << '\n';
// YOUR BUSINESS CODE: replace environment values with the payment intent
// selected by your own application or AI Agent.
const json payment = {{"amount", env("SPENDKIT_AMOUNT", "0.01")},
{"recipient", env("SPENDKIT_RECIPIENT")}, {"token", "USDG"}};
if (payment.at("recipient").get<std::string>().empty()) {
std::cout << "Set SPENDKIT_RECIPIENT to preview a payment\n";
curl_global_cleanup();
return 0;
}
const json preview = mcp.tool("spendkit_preview_payment", payment);
std::cout << "Preview: " << preview.at("allowed") << " " << preview.value("reason", "") << '\n';
if (preview.at("allowed") != true || env("SPENDKIT_ENABLE_PAYMENT") != "1") {
curl_global_cleanup();
return 0;
}
auto sender = createPaymentWalletSender(); // Fail before authorization until implemented.
const std::string key = env("SPENDKIT_IDEMPOTENCY_KEY");
if (key.empty()) throw std::runtime_error("Set one stable SPENDKIT_IDEMPOTENCY_KEY per logical payment");
json request = payment;
request["idempotencyKey"] = key;
const json auth = mcp.tool("spendkit_authorize_payment", request);
if (!auth.value("allowed", false) || !auth.contains("transaction"))
throw std::runtime_error("No new transaction authorized; inspect intent status");
const json transaction = auth.at("transaction");
const std::string wallet = connection.at("agent").at("paymentWallet").at("address").get<std::string>();
if (!sameAddress(transaction.at("from").get<std::string>(), wallet)
|| !sameAddress(auth.at("authorization").at("paymentWallet").get<std::string>(), wallet)
|| !sameAddress(auth.at("authorization").at("recipient").get<std::string>(), payment.at("recipient").get<std::string>())
|| auth.at("authorization").at("amount") != preview.at("amountAtomic")
|| !sameAddress(auth.at("authorization").at("token").get<std::string>(), preview.at("policy").at("tokenAddress").get<std::string>())
|| !sameAddress(transaction.at("to").get<std::string>(), connection.at("network").at("routerAddress").get<std::string>())
|| transaction.at("chainId") != connection.at("network").at("chainId"))
throw std::runtime_error("Authorization does not match intended payment or bound wallet");
const std::string hash = sender(transaction);
const std::string intent = auth.at("intentId").get<std::string>();
// KEEP THIS CALL after wallet submission: intentId + txHash lets Spendkit
// track payment promptly. Without it, Activity/status can lag until onchain
// reconciliation; the Router still enforces spending limits.
const json recorded = mcp.tool("spendkit_record_payment", {{"intentId", intent}, {"txHash", hash}});
// If recording fails, retry with this intent/hash; never resend payment.
if (!recorded.value("accepted", false)) throw std::runtime_error("Record failed: " + recorded.dump());
// KEEP THIS CALL to reconcile the receipt; submitted is not final success.
const json status = mcp.tool("spendkit_get_payment_status", {{"intentId", intent}});
std::cout << "Payment status: " << status.at("status") << '\n';
curl_global_cleanup();
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
curl_global_cleanup();
return 1;
}
}
The default examples read and preview only. Before turning on SPENDKIT_ENABLE_PAYMENT=1, connect a wallet signer that owns the address returned by spendkit_get_connection. It must validate the authorized amount, recipient, chain, Router, and wallet, then submit the exact returned transaction. Spendkit Server does not receive the private key or broadcast the payment.
Read the payment steps → Read the raw HTTP request format → Give these examples to an AI coding tool →