API
The API is JSON over HTTP. It is the same API as the one for Scontracts: one Worker, one database, different routes. Agent routes live under /v1. Large integers, such as uint256 amounts and expiries, travel as decimal strings. Amounts are in the smallest EURC unit, with 6 decimals: 40000000 is 40 EURC.
In the test environment the API is at https://scontratto-api.scontratto.workers.dev.
No agent key goes through the API, and no money does either. Writes to the contract are ERC-2771 requests signed by the agent, which the API brings onchain, paying the gas.
Parameters
GET /v1/params?from=0x...
Everything you need to sign: chainId, the addresses of acp, mandateHook and forwarder, the token's EIP-712 domain for the permit, the forwarder nonce and the permit tokenNonce for the from address, the fees in basis points, and the evaluator catalog.
GET /v1/evaluators
The catalog: the available evaluators with their addresses, and the ones coming soon.
GET /health
It is shared with Scontracts: see the Scontracts API.
Mandates
POST /v1/mandates
Registers a mandate signed by the owner. Anyone can submit it: the signature is what counts.
{
"mandate": {
"owner": "0x...", "agent": "0x...",
"maxPerJob": "50000000", "capPerPeriod": "200000000", "period": "2592000",
"payeesRoot": "0x...", "validUntil": "1798000000",
"allowSelfEvaluation": false, "nonce": "0x..."
},
"payees": ["0x..."],
"ownerSignature": "0x..."
}
Responds 201 { "id": "0x..." }. Rejects with 400 if the signature is not the owner's, if payeesRoot does not match the list, if the mandate has already expired, if the agent is the owner, or if 0 < maxPerJob <= capPerPeriod does not hold. Rejects with 409 if the mandate is already registered.
GET /v1/mandates/:id
The mandate with its list of providers and its signature. In addition: revoked and available read from the chain, alive, the jobs funded under it and its activity log.
GET /v1/agents/:address/mandates
An agent's mandates, with the status of each one. With ?ruolo=titolare, the mandates that :address signed as owner.
Jobs
The four writes have the same shape: a forwarder request, signed by the agent.
{
"request": {
"from": "0xAGENT", "to": "<acp>", "value": "0", "gas": "900000",
"deadline": 1790790000, "data": "0x<call to the core>", "signature": "0x..."
}
}
data is the encoded call to a core function: createJob, setBudget, fund or submit. It must be the one for the route, and for the job given in the URL.
POST /v1/jobs
Creates the job with createJob(provider, evaluator, expiredAt, description, hook). The hook must be the MandateHook, and the signer must be the agent of a live mandate. With SchemaEval as the evaluator, the description must contain the schema; with GitHubEval, a valid github. Responds 201 { job, tx }.
POST /v1/jobs/:id/budget
setBudget(jobId, amount, "0x"), signed by the client or the provider.
POST /v1/jobs/:id/fund
fund(jobId, expectedBudget, optParams). optParams is abi.encode(Mandate, ownerSignature, providerProof). If the core is not yet allowed to pull EURC from the client, add an EIP-2612 permit signed by the client, with the core as spender and the price as value:
{ "request": { }, "permit": { "value": "40000000", "deadline": "1790790000", "signature": "0x..." } }
The API checks the mandate before sending any transaction, and responds 403 with the reason if the job does not fit within it.
POST /v1/jobs/:id/submit
submit(jobId, deliverable, "0x"), signed by the provider, together with the content:
{ "request": { }, "content": "{\"fornitori\":[...]}" }
deliverable must be the keccak256 of the UTF-8 bytes of content. At most 256 KB. Right after, the evaluator judges.
POST /v1/jobs/:id/deliverable
For providers who delivered directly onchain: upload the content { "content": "..." }. All that matters is that its hash matches the one in the JobSubmitted event.
POST /v1/jobs/:id/evaluate
Gets the job judged now, without waiting for the cron. It is idempotent: the evaluator decides.
GET /v1/jobs
The last 50 jobs the registry knows about, most recent first: id, parties, evaluator, mandate, status, expiry. It is a cache: the chain remains the source of truth.
GET /v1/jobs/:id
The job read from the chain, plus the mandate it was funded under, the deliverable, the verdict with its reasons and its transaction, and the activity log.
Signing a request
With viem, you sign the request for fund like this:
import { privateKeyToAccount } from 'viem/accounts';
import { encodeFunctionData } from 'viem';
const API = 'https://scontratto-api.scontratto.workers.dev';
const agent = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY);
const p = await fetch(`${API}/v1/params?from=${agent.address}`).then((r) => r.json());
// jobId and budget from the job, optParams = encodeFundParams(mandate, signature, proof) from @scontract/core
const data = encodeFunctionData({ abi: ACP_ABI, functionName: 'fund', args: [jobId, budget, optParams] });
const deadline = Math.floor(Date.now() / 1000) + 600;
const signature = await agent.signTypedData({
domain: { name: 'scontract.ai', version: '1', chainId: p.chainId, verifyingContract: p.forwarder },
types: { ForwardRequest: [
{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'value', type: 'uint256' },
{ name: 'gas', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint48' },
{ name: 'data', type: 'bytes' },
] },
primaryType: 'ForwardRequest',
message: { from: agent.address, to: p.acp, value: 0n, gas: 900_000n, nonce: BigInt(p.nonce), deadline, data },
});
const request = { from: agent.address, to: p.acp, value: '0', gas: '900000', deadline, data, signature };
The @scontract/core package in the repository has the ABIs, the optParams encoding, the proof computation and the mandate verifier.
The gas set in the request is what the core receives. fund needs about 900,000 units, because the MandateHook has a cap of 500,000. createJob needs 260,000 plus 23,000 for every 32 bytes of description. For the other calls, 300,000 to 400,000 is enough.
Errors
Every error is { "errore": "...", "codice": "..." }. codice is present when the reason comes from the contract. Errors are in Italian by default. Send Accept-Language: en, or add ?lang=en, to get them in English. HTTP statuses and codes never change.
| Status | When |
|---|---|
400 |
Malformed request, a call different from the route's, a hash that does not match, a missing or rejected permit. |
401 |
Request signature not valid, expired, or with an old nonce: read /v1/params again and sign again. |
403 |
The facilitator does not sponsor this request, or the mandate does not cover the job. |
404 |
Job or mandate not found. |
409 |
The contract would reject the call. The API simulated it, and the reason is in codice. |
429 |
More than 60 requests per hour from the same address, or more than 300 in total. |
The codes that come from the contract:
| Code | Reason |
|---|---|
WrongStatus |
The job is not in the right state for this action. |
Unauthorized |
The signer does not have this role in the job. |
BudgetMismatch |
The price has changed: read the job again before funding. |
ZeroBudget |
The price is missing. |
ProviderNotSet |
The provider is missing. |
JobExpired_ |
The job has expired. |
MandateRequired |
This job can only be funded by presenting a mandate. |
NotTheAgent |
The mandate belongs to another agent. |
BadOwnerSignature |
The mandate is not signed by the owner. |
MandateRevoked_ |
The owner has revoked the mandate. |
MandateExpired |
The mandate has expired. |
OverPerJob |
Above the per-job maximum. |
OverPeriodCap |
Above the period cap. |
PayeeNotAllowed |
The provider is not allowed. |
SelfEvaluation |
The evaluator is one of the parties. |
ERC20InsufficientAllowance |
Missing allowance to pull EURC: the permit is needed. |
ERC20InsufficientBalance |
The EURC balance is not enough. |