HTTP API
Every public route — who may call it, what it takes, what it returns, and how it refuses.
The API stores bytes, validates shapes, and gates access against contract state. It never authorizes money movement and never decides an outcome.
Base URL is per deployment: https://elgora.ai for Base mainnet and
https://staging.elgora.ai for the Base Sepolia staging deployment. The CLI
picks the one matching its selected chain; its --api-base-url flag and the
ELGORA_API_BASE_URL variable override that.
Protected routes use one signature per request — Request authorization. Routes marked public below take no signature at all.
Bounties
Read a bounty
GET /api/bounties/{bounty_id}Public. Returns the bounty's on-chain facts, its pinned Guardian roster, its active Submissions, and the committed challenge Markdown.
If the stored bytes do not hash to the bounty's on-chain spec_commitment, the
route does not serve them anyway: challenge comes back null with a
bounty_challenge_content_mismatch error alongside it. Wrong bytes are treated
as an integrity failure, not as content.
Once the bounty is final the same response carries the advisory
VerificationRecord inline as verification_record, a sibling of challenge.
Get-or-create: the first read after finality derives and caches it, every read
after that serves the cached one. It has no route of its own.
When it cannot be derived yet, verification_record_error carries the reason
instead — never both, and never at the cost of the read: the bounty's chain
facts and challenge still come back at 200.
Publish a challenge
POST /api/bounty-challenge-specs/prepare-publication?poster_address=0x…
Content-Type: text/markdown
Authorization: Elgora-Approval …The body is the raw bounty_challenge.md bytes — not JSON, not a wrapper.
Exactly one poster_address, and the signature must be from that address.
Validates the frontmatter contract, runs the
readiness review, stores the exact bytes,
derives spec_commitment, and returns prepared createBounty arguments plus
the Hub's current fee policy. The Poster's wallet sends the transaction; the
API does not.
Submissions
The {submission} segment differs between the write routes and the read route,
and mixing them up is the most common integration mistake.
Get an upload target
POST /api/bounties/{bounty_id}/submissions/{solver_address}/artifact-upload-url
Content-Type: application/json
Authorization: Elgora-Approval …
{ "ciphertext_sha256": "…", "ciphertext_byte_length": 184320 }Signed by that Solver. Returns a one-time upload URL and the deterministic storage locator, which the client must confirm equals the one it derived itself.
Prepare a Submission
POST /api/bounties/{bounty_id}/submissions/{solver_address}/prepare
Content-Type: application/json
Authorization: Elgora-Approval …Signed by that Solver. The body is the canonical envelope JSON. The route
re-verifies the envelope end to end — internal digests, the recipients
commitment against the bounty's pinned roster, the reveal policy against the
deadline, the storage locator, the uploaded ciphertext's integrity, and that
the bounty is still open — before storing anything, then returns the exact
submit call.
Read Submission content
GET /api/bounties/{bounty_id}/submissions/{submission_commitment}/content
Authorization: Elgora-Approval …Note: {submission_commitment}, not a Solver address. An authenticated,
empty-body GET. Authorization comes from ElgoraHub state — the funding Poster,
or a Guardian on that bounty's pinned roster, both fixed when the bounty was
created — read from Elgora's indexed copy of the ElgoraHub event log. Holding a
commitment is not access.
Returns the stored envelope's canonical JSON plus a short-lived ciphertext download URL. Everything it returns is encrypted.
Written Verdicts
POST /api/written-verdicts/{report_commitment}
Content-Type: text/markdown
Authorization: Elgora-Approval …
GET /api/written-verdicts/{report_commitment}The write is one raw Markdown document with strict frontmatter, signed by a Guardian on the bounty's pinned roster; for an awarded Verdict the route checks the named Submission is that Solver's current active one.
The read is public: it is how anyone confirms that the document behind a
report_commitment is the one anchored on-chain.
Winning-solution delivery
GET /api/deployment/delivery-key
POST /api/bounties/{bounty_id}/delivery/wrap
GET /api/bounties/{bounty_id}/delivery/status?submission_commitment=0x…
POST /api/bounties/{bounty_id}/delivery/retrieve| Route | Who | Notes |
|---|---|---|
delivery-key | Public | This deployment's X25519 delivery public key |
wrap | A Guardian on the pinned roster | Body { solver_address, submission_commitment, wrapped_key }. Checked against contract state, and the wrap must actually open with the deployment's key before it is stored. Idempotent |
status | Public | ?submission_commitment= is required; without it the route returns 400. Answers {"exists": true} or {"exists": false} for exactly that Submission — never the key, never which Guardian stored it |
retrieve | The funding Poster | Body { poster_ephemeral_public_key }. Permitted only when finalized state is awarded and names that Submission. Returns the content key rewrapped to the fresh key — never a raw stored key or a plaintext artifact |
Read Delivering the winning work before integrating this; the custody trade-off is real and stated there.
Payload rules
snake_casein every public payload.- Unknown keys at a write boundary fail. There is no lenient mode.
- Amounts are integer smallest-unit strings, bigint-compatible. No floats.
- Addresses, signatures,
bytes32values, timestamps, and artifact descriptors are validated before anything is persisted or prepared. - Two write boundaries take raw
text/markdownrather than JSON, because in both cases the committed artifact is the bytes.
Errors
{
"error": {
"code": "…",
"message": "…",
"issues": [{ "path": "…", "message": "…" }],
"next_action": "…"
}
}Readiness rejections (bounty_challenge_not_ready) use the same issue list.
path names the relevant topic or area of the page (at most 120 characters).
message explains the concern and requested correction (at most 1,500
characters), including supporting wording when useful. Read the feedback
against the whole page: locations and quoted wording are not mechanically
verified.
For publication_review_limit_reached (429), error.retry_not_before is an
ISO 8601 UTC timestamp and the HTTP Retry-After header gives the wait in
seconds. They identify when the current rolling-window limit permits another
attempt; other requests may consume that capacity before the retry arrives.
invalid_orchestrator_output (500) means the review could not be validated,
such as incomplete output or an invalid response shape. A 500 is not a
readiness rejection. Retry with a fresh signature and report persistent
failures; do not remove bounty requirements merely to work around the error.
See The readiness review for a feedback example.
| Status | Typical codes |
|---|---|
400 | invalid_bounty_detail_request, invalid_solver_submission, invalid_winning_solution_delivery, bounty_challenge_not_ready, frontmatter validation issues |
401 | unauthorized — stale freshness block, or a signature that does not verify |
403 | forbidden — valid signature, wrong wallet for this action |
409 | publication_review_unavailable — this exact signed request's review already ran or is running |
429 | publication_review_limit_reached — rolling 24-hour review quota |
500 | bounty_challenge_content_mismatch, solver_submission_content_invalid, invalid_orchestrator_output |
502 | orchestrator_provider_error |
503 | orchestrator_unconfigured, storage unavailable, bounty_submission_content_authority_unavailable, bounty_projection_unavailable, bounty_chain_state_unavailable — the indexed ElgoraHub state for this bounty has not reached a finalized block yet; retry shortly |
Raw provider, database, Solidity, or client-library errors are never surfaced as the primary message. When a route refuses because contract state does not permit the action, the refusal is the answer: re-read the chain rather than retrying with different framing.