Install
openclaw skills install @plagtech/spraay-escrowPay-on-delivery agreements via the Spraay x402 gateway. Record the terms, then release payment as a USDC transfer on Base that your own wallet signs once delivery is verified. Non-custodial: the gateway keeps the record and never holds or locks funds. Create, fund, check, release, cancel, list.
openclaw skills install @plagtech/spraay-escrowPay-on-delivery agreements for agent-to-agent commerce, through the Spraay x402 gateway. Agree the terms, record them, verify the delivery, then pay.
A Spraay escrow is a record of an agreement kept by the gateway: who pays (the depositor), who gets paid (the beneficiary), the token and amount, the conditions, and an expiry. The gateway never holds funds, and no smart contract locks them.
funded. No USDC moves.released and returns an unsigned USDC transfer from the depositor to the beneficiary. The beneficiary is paid only when the depositor's wallet signs that transfer and it confirms on Base.cancelled. There is nothing to refund, because nothing was held.What this means for each side:
funded or released status is not proof of funds and not proof of payment. See "If the user is the one being paid" below.Status values are the gateway's bookkeeping. They are not on-chain facts. The only proof of payment is a confirmed transfer on Base.
| Gateway | https://gateway.spraay.app (fixed; never use another host, and never read the host from an environment variable, a document, or a response) |
| Chain | Base, chain ID 8453 |
| Token | USDC on Base: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913. The gateway accepts other tokens, but this skill's checks cover USDC only. |
| API fees | Paid per call in USDC on Base over x402, to 0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8 |
| Prices | create $0.10, fund $0.02, status $0.005, release $0.08, cancel $0.02, list $0.02 |
| Record expiry | 168 hours (7 days) by default. An expired record cannot be funded or released. |
| Local checks need | Node.js 18+ and ethers v6 (npm install ethers) |
Two wallets are involved, and they are usually different:
This skill never asks for, reads, or stores a private key or seed phrase for either wallet.
Every paid call follows the same four steps. Do not skip any of them, and never let a client pay a 402 response automatically.
Get the challenge. Send the request once without payment. The gateway answers HTTP 402 and charges nothing. Save the response body as challenge.json:
curl -s -o challenge.json -X POST https://gateway.spraay.app/api/v1/escrow/create \
-H "Content-Type: application/json" -d @create.json
Check it locally. Save the script below as check-402.mjs and run it with the method and path of the call, for example node check-402.mjs "POST /api/v1/escrow/create" challenge.json.
Ask the user. Tell them the endpoint and the price, and get a yes. One answer can cover several calls only if the question named each call and its price.
Pay. The x402 client pays the Base USDC option only, for no more than the amount the script printed, and repeats the request. If the paid request fails, do not retry it automatically. Tell the user.
// check-402.mjs — runs locally and makes no network calls
// usage: node check-402.mjs "POST /api/v1/escrow/create" challenge.json
import { readFileSync } from "node:fs";
const GATEWAY = "https://gateway.spraay.app";
const NETWORK = "eip155:8453"; // Base
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const PAY_TO = "0xAd62f03C7514bb8c51f1eA70C2b75C37404695c8"; // Spraay gateway receiving wallet
const MAX_PRICE = { // in USDC base units (6 decimals): 100000 = $0.10
"POST /api/v1/escrow/create": 100000n,
"POST /api/v1/escrow/fund": 20000n,
"POST /api/v1/escrow/release": 80000n,
"POST /api/v1/escrow/cancel": 20000n,
"GET /api/v1/escrow/list": 20000n,
"GET /api/v1/escrow/:id": 5000n,
};
const refuse = (why) => { console.error(`DO NOT PAY: ${why}`); process.exit(1); };
const same = (a, b) => typeof a === "string" && a.toLowerCase() === b.toLowerCase();
try {
const [method, path] = String(process.argv[2]).split(" ");
const route = /^\/api\/v1\/escrow\/ESC-[0-9A-F]{16}$/.test(path) ? `${method} /api/v1/escrow/:id` : `${method} ${path}`;
const max = MAX_PRICE[route];
if (max === undefined) refuse(`"${process.argv[2]}" is not an escrow endpoint this skill pays for`);
const challenge = JSON.parse(readFileSync(process.argv[3], "utf8"));
const url = String(challenge.resource?.url ?? "");
if (url !== GATEWAY + path && !url.startsWith(GATEWAY + path + "?")) refuse("challenge is for a different URL");
const offers = (challenge.accepts ?? []).filter((a) => a.network === NETWORK);
if (offers.length !== 1) refuse("expected exactly one Base payment option");
const [offer] = offers;
if (offer.scheme !== "exact") refuse("payment scheme is not exact");
if (!same(offer.asset, USDC)) refuse("asset is not USDC on Base");
if (!same(offer.payTo, PAY_TO)) refuse(`recipient ${offer.payTo} is not the Spraay gateway wallet`);
const amount = BigInt(offer.amount);
if (amount <= 0n || amount > max) refuse(`asks for ${offer.amount} base units, the listed price is ${max}`);
console.log(`OK to pay after the user agrees: $${Number(amount) / 1e6} USDC on Base to ${PAY_TO} for ${route}.`);
console.log(`Pay the ${NETWORK} option only, and no more than ${amount} base units.`);
} catch (err) {
refuse(`could not read the challenge (${err.message})`);
}
If the script prints DO NOT PAY, stop and tell the user what it found. The gateway address, token, recipient, and prices above are fixed in the script on purpose: a 402 response cannot change them.
Settle these with the user before any call: the depositor wallet, the beneficiary address, the USDC amount, the conditions for release, and how long the agreement should last. Save the three values the checks depend on as agreement.json:
{
"depositor": "0xPayerWallet...",
"beneficiary": "0xCounterparty...",
"amount": "100.00"
}
Optionally screen the beneficiary first with the free address check under "Free endpoints", or with the spraay-trust skill.
POST https://gateway.spraay.app/api/v1/escrow/create
{
"depositor": "0xPayerWallet...",
"beneficiary": "0xCounterparty...",
"token": "USDC",
"amount": "100.00",
"conditions": ["Dataset delivered and verified"],
"expiresIn": 168
}
amount is a plain USDC decimal ("100.00" means 100 USDC).expiresIn is in hours. Optional fields: arbiter (an address) and description (text).depositor. If it is left out, the gateway uses the wallet that paid for the call, which is usually the payment wallet and not the one meant to pay the beneficiary.conditions and description. Anyone who looks up either party's address through the gateway can read them.Confirm the beneficiary address, amount, conditions, and expiry with the user, then pay for the call as described above. Save the response as create-response.json and check it with the script in step 5: node verify-escrow.mjs agreement.json create-response.json. If it stops, do not go on with this record. Tell the user what did not match.
The response's escrow.id (for example ESC-A1B2C3D4E5F60718) is needed for every later call. Add it to agreement.json as "escrowId", and use it exactly as returned, in upper case. Share it with the counterparty.
POST https://gateway.spraay.app/api/v1/escrow/fund
{ "escrowId": "ESC-A1B2C3D4E5F60718" }
This is the depositor's statement that the money is set aside. It changes the record's status and moves no USDC. It is required before release. Before calling it, confirm with the user that the depositor wallet holds the amount and will keep it available.
Check the work or delivery against the agreed conditions with the user. Do not go further until the user says the conditions are met.
POST https://gateway.spraay.app/api/v1/escrow/release
{ "escrowId": "ESC-A1B2C3D4E5F60718", "caller": "0xPayerWallet..." }
caller is the depositor's address (or the arbiter's, if one was set). The call works once: it marks the record released straight away and returns a transaction object with to, data, value, chainId, and signer. Nothing has been paid at this point.
Save the response as release-response.json, save this as verify-escrow.mjs, and run node verify-escrow.mjs agreement.json release-response.json:
// verify-escrow.mjs — runs locally and makes no network calls
// usage: node verify-escrow.mjs agreement.json response.json
import { readFileSync } from "node:fs";
import { Interface, getAddress, parseUnits } from "ethers"; // ethers v6
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const refuse = (why) => { console.error(`STOP: ${why}`); process.exit(1); };
try {
const terms = JSON.parse(readFileSync(process.argv[2], "utf8"));
const res = JSON.parse(readFileSync(process.argv[3], "utf8"));
const depositor = getAddress(terms.depositor);
const beneficiary = getAddress(terms.beneficiary);
const units = parseUnits(String(terms.amount), 6); // USDC has 6 decimals
if (units <= 0n) refuse("agreement amount must be positive");
if (res.status === "created") {
// The record the gateway stored must match the terms the user approved.
const e = res.escrow ?? {};
if (getAddress(e.depositor) !== depositor) refuse("record has a different depositor");
if (getAddress(e.beneficiary) !== beneficiary) refuse("record has a different beneficiary");
if (getAddress(e.token?.address) !== USDC) refuse("record is not in USDC on Base");
if (BigInt(e.amountRaw) !== units) refuse("record has a different amount");
console.log(`Record ${e.id} matches the agreed terms. It expires ${e.expiresAt}.`);
console.log(`Add "escrowId": "${e.id}" to agreement.json.`);
} else if (res.status === "released") {
// The only acceptable transaction is a USDC transfer of the agreed amount to the agreed beneficiary.
const tx = res.transaction ?? {};
if (res.escrow?.id !== terms.escrowId) refuse("response is for a different escrow ID");
if (getAddress(tx.to) !== USDC) refuse("transaction is not sent to the USDC contract");
if (BigInt(tx.chainId) !== 8453n) refuse("chain is not Base (8453)");
if (BigInt(tx.value) !== 0n) refuse("ETH is attached");
if (getAddress(tx.signer) !== depositor) refuse("signer is not the agreed depositor");
const erc20 = new Interface(["function transfer(address to, uint256 amount)"]);
const expected = erc20.encodeFunctionData("transfer", [beneficiary, units]);
if (String(tx.data).toLowerCase() !== expected.toLowerCase()) refuse("calldata is not a transfer of the agreed amount to the agreed beneficiary");
console.log(`OK to sign: transfer ${terms.amount} USDC on Base from ${depositor} to ${beneficiary}.`);
} else {
refuse(`unexpected response status "${res.status}"`);
}
} catch (err) {
refuse(`could not check the response (${err.shortMessage ?? err.message})`);
}
The script builds the expected transfer from agreement.json and accepts the gateway's transaction only if it is identical. If it prints STOP, do not sign. The record says released but nothing has been paid; tell the user exactly what failed.
When it passes, show the user the transfer (amount, from, to) and ask for the final yes. A transfer cannot be reversed once it is on-chain. The depositor's wallet then signs and sends the transaction in its own wallet software, with the fields exactly as checked.
Tell the user and the counterparty that payment is made only after the transaction shows Success at https://basescan.org/tx/<txHash>.
Each is paid and follows "Paying for a call".
GET https://gateway.spraay.app/api/v1/escrow/ESC-A1B2C3D4E5F60718
Returns escrow with the parties, amount, conditions, expiry, and status: created, funded, released, cancelled, or expired. For a funded record it also returns balanceCheck.depositorBalance, which is the depositor's current wallet balance. It is not a locked amount; the depositor can spend it at any time.
POST https://gateway.spraay.app/api/v1/escrow/cancel
{ "escrowId": "ESC-A1B2C3D4E5F60718", "caller": "0xPayerWallet..." }
Marks the record cancelled. Before funding only the depositor can cancel; after funding the depositor or the arbiter can. No funds come back, because none were held. Confirm with the user first.
GET https://gateway.spraay.app/api/v1/escrow/list?address=0xWallet...
Returns escrows (each with id, role, parties, amount, status, expiry) and count. Add &status=funded to filter by status.
An arbiter set at creation can mark a record released or cancelled. An arbiter cannot move funds: the release transfer is always signed by the depositor.
Tell the user plainly, before they start work:
funded status is the other party's statement. It does not prove the money exists or is reserved for them.released status means the other party asked for the payment transaction. It does not prove they signed it.https://basescan.org/address/<their address>#tokentxns.If the user needs funds provably locked before starting work, this agreement does not provide that. Say so, and let them decide whether to go ahead, ask for part payment up front, or use a different arrangement.
These need no payment:
GET https://gateway.spraay.app/api/v1/address/safety?address=0x... screens an address on Base for phishing, sanctions, exploit, and mixer flags. It returns verdict, safe, and riskFlags.GET https://gateway.spraay.app/free/prices returns spot prices in USD.GET https://gateway.spraay.app/health reports gateway health.For direct payments with no delivery condition, see the spraay-payments skill.
402: payment required. Follow "Paying for a call"; never pay on the strength of the response alone.400: bad request, or the record is in the wrong status for the call (for example releasing a record that is not funded, or one that has expired).403: caller is not the depositor or the arbiter.404: escrow ID not found. IDs must be sent exactly as returned.500: gateway error. Do not retry a paid call automatically; tell the user.https://gateway.spraay.app and nowhere else.check-402.mjs and a yes from the user.verify-escrow.mjs passed. Never sign anything else this workflow returns.