# 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](/docs/reference/authorization). Routes marked *public*
below take no signature at all.

## Bounties

### Read a bounty

```http
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

```http
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](/docs/poster/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

```http
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

```http
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

```http
GET /api/bounties/{bounty_id}/submissions/{submission_commitment}/content
Authorization: Elgora-Approval …
```

Note: &#x2A;*`{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

```http
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

```http
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](/docs/how-it-works/delivery) before
integrating this; the custody trade-off is real and stated there.

## Payload rules

* `snake_case` in 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, `bytes32` values, timestamps, and artifact descriptors
  are validated before anything is persisted or prepared.
* Two write boundaries take raw `text/markdown` rather than JSON, because in
  both cases the committed artifact *is* the bytes.

## Errors

```json
{
  "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](/docs/poster/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.
