Skip to content

HTTP API

The base address is http://localhost:3000. Every response is JSON, and the session is kept in the HttpOnly cookie umul_session.

Authentication

POST /v1/auth/nonce

{ "address": "0xEf792c4973Cc8ef6aF433B3bd9f4461B701301f6" }

The response is the SIWE message to sign and its expiry. The nonce expires after 5 minutes and can be used only once. What gets stored is a SHA-256 hash, not the original value.

{ "message": "localhost:5173 wants you to sign in ...", "expiresAt": "..." }

POST /v1/auth/verify

{ "message": "<the message received above>", "signature": "0x..." }

If the address recovered from the signature differs from the address in the message, the request is rejected. On success it returns Set-Cookie: umul_session=...; HttpOnly; SameSite=Lax.

DELETE /v1/auth/session

Revokes the session and expires the cookie. Returns 204 even when there is no session.

Error code Status
INVALID_REQUEST 400
INVALID_WALLET_ADDRESS, INVALID_LOGIN_MESSAGE, LOGIN_MESSAGE_MISMATCH 400
INVALID_WALLET_SIGNATURE, LOGIN_NONCE_UNKNOWN 401
LOGIN_ORIGIN_NOT_ALLOWED 403
LOGIN_NONCE_ALREADY_USED 409
LOGIN_NONCE_EXPIRED 410

INVALID_REQUEST means the request body did not match the schema.

Evidence

Every evidence endpoint requires a valid session. Without one it returns 401 SESSION_REQUIRED. For an address that is not a participant it returns 404 EVIDENCE_REQUEST_NOT_FOUND rather than revealing whether the record exists.

POST /v1/evidence-requests

An Idempotency-Key header is required. Repeating the same key returns the existing request instead of creating a new one.

{
  "name": "Contract terms agreed",
  "messageRefs": ["<xmtp-message-id>", "..."],
  "evidenceId": "0x...",
  "contentHash": "0x...",
  "conversationIdHash": "0x...",
  "participantsHash": "0x...",
  "manifestVersion": "gasok-evidence-v1",
  "messageCount": 12,
  "conversationStartedAt": "2026-07-26T00:00:00.000Z",
  "conversationEndedAt": "2026-07-26T01:00:00.000Z",
  "participants": ["0x...", "0x..."]
}

Every field is required, and undefined fields are rejected.

Field Constraint
name 1–100 characters. A human-readable label that never goes on chain
messageRefs 1–4096 XMTP message IDs. No message bodies are accepted. The count must equal messageCount
manifestVersion 1–64 characters
messageCount At least 1
participants 2–32 addresses

If the session address is not in participants, the response is 403. Message plaintext is never accepted.

GET /v1/evidence-requests

Returns only requests where the session address is a participant, newest first. Saved items come first.

GET /v1/evidence-requests/:id

The response includes name and messageRefs along with saved. saved is the requesting participant's own saved state, so the same evidence yields a different value per person. Signatures and participant addresses are exposed; session tokens and message plaintext are not.

POST /v1/evidence-requests/:id/save

Saves (favourites) or unsaves the evidence. This is per-participant state, so one person saving does not affect anyone else's list.

{ "saved": true }

POST /v1/evidence-requests/:id/signatures

{ "signature": "0x<130 hex>" }

The signer is determined by the session address. An address cannot be supplied in the request body.

POST /v1/evidence-requests/:id/rejection

POST /v1/evidence-requests/:id/submission

{ "transactionHash": "0x...", "outcome": "confirmed", "blockNumber": 31763659 }

Omitting outcome records only up to SUBMITTED.

POST /v1/evidence-requests/:id/archive

{ "ciphertext": "<base64>" }

Uploads the archive of the evidenced plaintext. Only the ciphertext locked by the client with AES-256-GCM is accepted; the content key travels solely over an XMTP control message (E2EE), so the server cannot open it. A non-proposer gets 403 EVIDENCE_ARCHIVE_FORBIDDEN. It is stored exactly once — a retry with identical content succeeds, and different content returns 409 EVIDENCE_ARCHIVE_CONFLICT. Ciphertext is accepted from the minimum GCM envelope length (29 bytes) up to 2 MB.

POST /v1/evidence-requests/:id/archive/keys

{ "wrappedKey": "<base64, 80 chars>" }

A participant who has finished signing deposits their wrapped content key (fixed 60 bytes). The wrapping key is locked with a key derived from a wallet signature, so the server cannot open it. Before signing, the response is 403 EVIDENCE_ARCHIVE_FORBIDDEN. Storage rules match archive (first-write-wins, 409 on a different value).

GET /v1/evidence-requests/:id/archive

{ "ciphertext": "<base64>", "wrappedKey": "<base64|null>" }

Only participants who signed receive the ciphertext and their own wrapped key. Participants who did not sign, and requests with no archive, both get 404 EVIDENCE_ARCHIVE_NOT_FOUND so existence is not revealed. A null wrappedKey means a signer whose key registration failed.

Evidence states

PROPOSED -> SIGNING -> READY -> SUBMITTED -> CONFIRMED
     |          |         |          |
     +------> REJECTED    +------> FAILED
     +------> EXPIRED

From FAILED, resubmission is possible while keeping the collected signatures.

Error code Status
INVALID_REQUEST, IDEMPOTENCY_KEY_REQUIRED 400
EVIDENCE_HASH_MALFORMED, EVIDENCE_SIGNATURE_MALFORMED, EVIDENCE_ARCHIVE_MALFORMED 400
EVIDENCE_NAME_INVALID, EVIDENCE_MESSAGE_REFS_MISMATCH, EVIDENCE_TIME_RANGE_INVALID 400
EVIDENCE_PARTICIPANTS_TOO_FEW, EVIDENCE_PARTICIPANTS_DUPLICATED 400
EVIDENCE_REQUESTER_NOT_PARTICIPANT 400
SESSION_REQUIRED 401
EVIDENCE_PARTICIPANT_UNKNOWN, EVIDENCE_ARCHIVE_FORBIDDEN 403
EVIDENCE_REQUEST_NOT_FOUND, EVIDENCE_ARCHIVE_NOT_FOUND 404
EVIDENCE_SIGNATURE_ALREADY_SUBMITTED, EVIDENCE_ARCHIVE_CONFLICT 409
EVIDENCE_REQUEST_NOT_READY, EVIDENCE_REQUEST_NOT_SIGNABLE 409
EVIDENCE_REQUEST_NOT_REJECTABLE, EVIDENCE_REQUEST_NOT_SUBMITTED 409
EVIDENCE_REQUEST_EXPIRED 410

EVIDENCE_REQUESTER_NOT_PARTICIPANT is 400, not 403. A proposer leaving themselves out of participants is a malformed request, not a permission problem.

Health check

GET /health returns { "status": "ok", "time": "<ISO 8601>" }.