ITEN
Demo · Base Sepolia · test money

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.