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>" }.