# HTTP API

> Every public route — who may call it, what it takes and what it returns.

The API stores content and checks requests against contract state. It never
moves money and never decides an outcome.

The base URL is `https://elgora.ai` for Base mainnet and
`https://testnet.elgora.ai` for the Base Sepolia testnet. The CLI picks the
one for its chain; `--api-base-url` or `ELGORA_API_BASE_URL` overrides it.

Routes marked *public* need no signature. Every other route takes one signature
per request: see [Request authorization](/docs/reference/authorization).

## Bounties

### Read a bounty

```http
GET /api/bounties/{bounty_id}
```

**Public.** Returns the bounty's on-chain facts, its Guardian roster, its
active Submissions and its challenge Markdown. If the stored challenge does not
match the bounty's `spec_commitment`, `challenge` is `null` and the error is
`bounty_challenge_content_mismatch`. Once the bounty is final, the response
also carries its `verification_record`, or `verification_record_error` while
it cannot be built yet.

### Review a challenge

```http
POST /api/bounty-challenge-specs/review?poster_address=0x…
Content-Type: text/markdown
Authorization: Elgora-Approval …
```

The body is the raw challenge Markdown, signed by `poster_address`. Add
`previous_spec_commitment=0x…` to the query, before signing, to learn which
issues of an earlier review you resolved.

A ready page returns
`{ content_record, readiness: { decision: "ready", approved_until, warnings, prior_issues_resolved, prior_issues_open } }`.
The approval of these exact bytes lasts until `approved_until`, seven days.
`warnings` never block publication. A page that is not ready returns `400`
`bounty_challenge_not_ready` with its issues. Reviewing the same approved bytes
again within seven days costs no quota.

### 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, signed by `poster_address`.
The page must name the current profile, `elgora_markdown_bounty_challenge_v1`;
any other returns `profile_not_current`. Bytes without an approval from the last
seven days are reviewed first. The route stores the bytes and returns the
`spec_commitment`, the `createBounty` arguments and the current fee policy. Your
wallet sends the transaction.

## Submissions

The write routes take the Solver's address; the read route takes the
`submission_commitment`.

### 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 storage locator.

### 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 envelope's canonical JSON. The route
checks it against the bounty and the uploaded ciphertext, stores it and
returns the exact `submit` call. An envelope sealed for a different profile
than the bounty's returns `invalid_solver_submission`.

### Read Submission content

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

Signed by the funding Poster or a Guardian on the bounty's roster. Returns the
envelope's canonical JSON and a short-lived download URL for the ciphertext.
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 Markdown document, signed by a Guardian on the bounty's
roster. The read is **public**: anyone can confirm the document behind an
on-chain `report_commitment`.

## 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 roster | Body `{ solver_address, submission_commitment, wrapped_key }`                                                      |
| `status`       | Public                   | `submission_commitment` is required. Returns `{"exists": true}` or `{"exists": false}`                             |
| `retrieve`     | The funding Poster       | Body `{ poster_ephemeral_public_key }`. Only for an `awarded` bounty. Returns the content key, wrapped to your key |

See [Delivering the winning work](/docs/how-it-works/delivery) for the custody
trade-off.

### Open the winning Submission

`elgora-cli poster:open-winning-submission` does all of this for you, and the
bounty page does it in the browser. To do it yourself, sign both requests with
the funding wallet.

Conventions: base64url is unpadded; canonical JSON is RFC 8785; digests are
lowercase hex, `keccak256` with `0x` and `sha256` without. `<chain_id>` and
`<hub_address>` belong to the bounty's network: see
[Addresses](/docs/reference/addresses).

1. **Make a one-time key.** Generate an X25519 key pair; encode each key as
   base64url.
2. **Retrieve the content key.** `POST /api/bounties/<bounty_id>/delivery/retrieve`
   with `{"poster_ephemeral_public_key":"<public key>"}`. It returns
   `{"submission_commitment":"0x…","wrapped_key":{"enc":"…","ciphertext":"…"}}`.
3. **Unwrap it.** Let `context_commitment` be the `keccak256` of the canonical
   JSON of `{"bounty_id":"<bounty_id>","chain_id":<chain_id>,"hub_address":"<hub_address>","submission_commitment":"<submission_commitment>"}`.
   Open `wrapped_key` with HPKE (RFC 9180) base mode, DHKEM(X25519,
   HKDF-SHA256), HKDF-SHA256, AES-256-GCM, empty AAD, and info
   `elgora/winning-solution-delivery/hpke-aes-key/v0:<context_commitment>`.
   The result is the 32-byte content key.
4. **Fetch the envelope.** `GET /api/bounties/<bounty_id>/submissions/<submission_commitment>/content`.
   It returns `content_record.canonical_json` (the envelope) and
   `ciphertext_download_url`.
5. **Check the envelope.** Its `keccak256` is `submission_commitment`, and its
   `context` names your `chain_id`, `hub_address` and `bounty_id`.
6. **Download** `ciphertext_download_url`. Its `sha256` is
   `envelope.ciphertext_sha256`.
7. **Decrypt** with AES-256-GCM: the content key, IV `envelope.iv`, the 16-byte
   tag at the end of the download, and as AAD the canonical JSON of the
   envelope's `kind`, `alg`, `context`, `public_manifest_sha256`,
   `encrypted_bundle_sha256`, `guardian_recipients_commitment`,
   `reveal_policy` and `envelope_nonce`.
8. **Unpack.** The plaintext's `sha256` is `envelope.encrypted_bundle_sha256`.
   It is a gzipped tar of the Submission's files: `tar -xzf` it into a new,
   empty folder.

A bounty on `elgora_markdown_bounty_challenge_v0` unpacks differently at step
8; see [Winners of v0 bounties](/docs/poster/v0-winners).

## Protocol profile

```http
GET /api/protocol/profile/{version}
```

**Public.** Returns the fixed limits a bounty's `profile:` names, such as
`elgora_markdown_bounty_challenge_v1`: Submission size, file count, names and
paths under `submission`, and challenge page limits under `challenge`. An
unknown version returns `404` `protocol_profile_not_found`. Fees and windows
are read from `ElgoraHub`; see [Limits and control](/docs/how-it-works/limits).

## Payload rules

* Every public payload uses `snake_case`.
* An unknown key in a write fails.
* Amounts are integer strings in the token's smallest unit.
* The two challenge routes take raw `text/markdown`, because the committed
  content is the bytes themselves.

## Errors

```json
{
  "error": {
    "code": "…",
    "message": "…",
    "issues": [{ "path": "…", "message": "…" }],
    "next_action": "…"
  }
}
```

Follow `next_action` when it is present. For `publication_review_limit_reached`
(`429`), `error.retry_not_before` and the `Retry-After` header say when to try
again. A `500` from the review is not a readiness verdict; retry it. When
contract state does not permit an action, re-read the chain rather than
retrying.

| Status | Typical codes                                                                                                                                       |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_bounty_detail_request`, `invalid_solver_submission`, `invalid_winning_solution_delivery`, `bounty_challenge_not_ready`, frontmatter issues |
| `401`  | `unauthorized`: a stale or invalid signature                                                                                                        |
| `403`  | `forbidden`: the wrong wallet for this action                                                                                                       |
| `404`  | `delivery_key_wrap_not_found`: no awarded winner yet, or no Guardian has delivered its key                                                          |
| `429`  | `publication_review_limit_reached`: your 24-hour review quota                                                                                       |
| `500`  | `bounty_challenge_content_mismatch`, `solver_submission_content_invalid`, `invalid_orchestrator_output`                                             |
| `502`  | `orchestrator_provider_error`                                                                                                                       |
| `503`  | `orchestrator_unconfigured`, storage or chain state unavailable                                                                                     |
