Skip to main content
A release contract states what a paying client depends on: the status code, the content type and the shape of the body. Selo checks the paid response against it in the response_contract check. A mismatch is a proven FAIL, and a FAIL blocks the release.

The format

A contract is a JSON object with three optional fields and nothing else:
contract.json
integer
default:"200"
The expected HTTP status of the paid response, from 100 to 599.
string
The expected media type, written as type/subtype, at most 200 characters. It is compared case-insensitively with the media type of the response’s Content-Type, ignoring parameters, so application/json; charset=utf-8 matches application/json. Omit it to skip the check.
object
A JSON Schema the parsed response body must satisfy, at most 16 KiB once serialized. Omit it to skip body validation.
Any other top-level key is rejected. An empty contract {} checks only that the status is 200. The file has the shape of the expected field of POST /v1/release-test. selo test --contract contract.json sends it as expected, and a raw API client sends the same object. See Pay with any x402 client.

How a contract is compiled

The schema is compiled with Ajv 8 in strict mode, using Ajv’s default JSON Schema dialect, draft-07. A contract is compiled twice before anyone pays:
  • by the CLI, which exits 3 before sending any request;
  • by Selo’s API, which answers 400 VALIDATION_FAILED with the message expected: <issue> before asking for payment.
These are the errors you are most likely to meet. The CLI prints each message after the contract file <path> is not usable:, and the API after expected:. Schema errors start with jsonSchema failed to compile:, followed by Ajv’s own message. A top-level key other than status, contentType and jsonSchema is rejected before compiling. The CLI prints the contract file contract.json must be { status?, contentType?, jsonSchema? }: Unrecognized key: "expected", and the API answers 400 VALIDATION_FAILED. These compile without trouble: title, description and examples; const and enum; pattern on a string; nested objects and arrays; $ref into definitions or $defs; a nullable type written as ["string", "null"]; and "$schema": "http://json-schema.org/draft-07/schema#".

How a paid response is evaluated

Selo reads the paid response body, up to 256 KiB, then checks in this order and reports the first mismatch:
1

Status

The response status must equal status.
2

Content type

If contentType is set, the response’s media type must match it.
3

Body

If jsonSchema is set, the body must be non-empty, valid JSON, and satisfy the schema. Only the first schema error is reported.
A SCHEMA_MISMATCH summary is the error’s location and message, cut at 200 characters. response_contract is a blocking check, so a FAIL with evidence makes the verdict FAIL. See The five release checks and Verdicts and the gate.

Worked example: the demo merchant

The demo merchant sells one order and returns a receipt. Its contract is in the Selo repository at examples/merchant/contract.json:
contract.json
It ships two releases at the same URL. Both answer 200 with application/json after the payment settles. The difference is one field name:
On Algorand Mainnet on 26 October 2026, Selo tested both. These are the recorded response_contract results: A health check, a status check and a content-type check all pass on broken-v1. Only the schema catches the renamed field. The extra fields sku, quantity and release do not matter, because the schema does not close the object.

Which contract did a verdict use?

Every signed attestation records expectedContractSha256: the SHA-256 of the contract as canonical JSON, with keys sorted at every level and no whitespace. You can recompute it from your file:
Terminal
Output
That is the expectedContractSha256 in both of the demo merchant’s Mainnet attestations. See Verify a signed verdict.

Tips for a good contract

List in required every field a buyer’s integration depends on, and nothing it does not. A field you plan to remove should not be required.
additionalProperties: false turns every new field into a FAIL. Use it only if adding a field really breaks your clients.
No format is available, so date-time, email or uuid fail to compile. Use pattern on a string for id and date shapes, and const for values that must never change, such as "fulfilled": { "const": true }.
Strict mode wants "type": "object" next to required and properties, "type": "string" next to pattern, and a numeric type next to minimum or maximum.
status defaults to 200, and the content type is not checked unless you set it. A route that starts answering 201 or text/plain is caught only if you say what you expect.
Commit the contract, for example as selo/contract.json, so it changes in the same pull request as the response. The demo merchant has a test that keeps contract.json equal to the receipt schema it advertises in its Bazaar listing.
Only the first schema error is reported. After you fix it, another mismatch may surface on the next run.

Next

Run a release test

Pass the contract with --contract and gate on the verdict.

Try it with the demo merchant

Reproduce the FAIL and the PASS yourself.