This document describes how to integrate the Fibre gateway. The request and response formats are in the API reference. What we promise is in the service contract.
- Quick start
- Terms
- Connect
- Tokens
- Blob sizes
- Limits
- Capacity
- Timeouts
- Errors and retries
- Retention and what to store
- Verification
- Client libraries
This gateway provides easy access to a Celestia Fibre deployment. The gateway accepts a raw payload, Reed-Solomon encodes it, and sends the resulting blob to Celestia for storage.
On success, the gatway returns a blob_id - a binding commitment to the posted data. Before accepting, clients should verify that the returned commitment matches their payload
using the provided library (see Rust example below).
| Item | Value |
|---|---|
| Endpoint | https://cf.celestia-corto.com:8443 |
| Token | A bearer token from Celestia, sent out of band |
| Code | Read access to celestiaorg/fibre-gateway-client, for the Rust and Python libraries |
| A test blob | 131,067 to 134,217,723 bytes (128 KiB − 5 to 128 MiB − 5) |
Note: The −5 reserves space for Fibre's 5-byte blob header. The gateway adds this header for you; send only your data.
Send the token using an authroization header (Authorization: Bearer <token>) on every request.
To minimize costs, the gateway scales down after inactivity. Before running a load test, we recommend scaling it up manually to ensure that capacity is available.
| Endpoint | Meaning |
|---|---|
POST /v1/capacity with {"gib_per_second":10,"minutes":60} or {"instances":20,"minutes":60} |
Keep at least that throughput or instances (2–20) up for minutes (1–120) |
GET /v1/capacity |
Current reservations, fleet size and load |
DELETE /v1/capacity |
Release your reservation early |
GET /v1/capacity/load?minutes=60 |
Fleet put load history (what the page plots) |
Or use the web page at https://cf.celestia-corto.com:8443/ui: enter GiB/s and minutes, and it tells you when the
capacity is serving.
GW=https://cf.celestia-corto.com:8443
TOKEN=<your token>
head -c 134217723 /dev/urandom > blob.bin # 128 MiB − 5, the largest size
curl --fail-with-body --max-time 160 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @blob.bin "$GW/v1/put" -o put.json
curl --fail-with-body --max-time 130 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data "{\"blob_id\":\"$(jq -r .blob_id put.json)\"}" "$GW/v1/get" -o blob.out
cmp blob.bin blob.out && echo okThe put takes a few seconds: it returns after the chain confirms the blob.
put.json looks like this:
{"chain_id":"corto-10","tx_hash":"<64 hex>","blob_id":"<66 hex>","promise_height":123,
"commitment_proof":{"row_root_siblings":["<64 hex>","<64 hex>"]}}Fields are described in the API reference
The crate fibre-gateway-client is on crates.io. Its source is in clients/rust.
[dependencies]
fibre-gateway-client = { version = "0.1", features = ["http"] }
bytes = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"This is clients/rust/examples/quickstart.rs:
use bytes::Bytes;
use fibre_gateway_client::Client;
const GATEWAY: &str = "https://cf.celestia-corto.com:8443";
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::args().nth(1).ok_or("usage: quickstart <blob>")?;
let data = Bytes::from(std::fs::read(path)?);
let client = Client::new(GATEWAY, std::env::var("TOKEN")?);
// `put` checks the commitment proof against `data` before it returns the receipt.
let receipt = client.put(data.clone()).await?;
// After the check, `blob_id` is all you need to keep.
std::fs::write("blob_id.txt", &receipt.blob_id)?;
let back = client.get(&receipt.blob_id).await?;
if back.as_slice() != data.as_ref() {
return Err("read-back does not match".into());
}
println!("ok {} {}", receipt.blob_id, receipt.tx_hash);
Ok(())
}Run it from clients/rust:
TOKEN=<your token> cargo run --release --features http --example quickstart -- blob.binYou do not need to verify anything yourself: Client::put checks the commitment against your data
before it returns, and fails with HttpError::Verify if it does not match.
In Python, call verify from fibre_verify.py once after the put (Python).
The Client already uses the correct timeouts (160 s put, 130 s get, 10 s connect) and never replays a put.
- Errors and retries: which errors to retry, and why a retried put may store a second copy.
- Limits: sizes, concurrency per instance, retention.
- Connect: keep-alive and spreading load over instances.
- Blob sizes: the size range, and which blobs pay for padding.
- Retention and what to store: keep the
blob_id, the put time and, if you padded, the real length. Blobs can be read for 24 h. - Capacity: reserve instances before a large job.
- On-chain check (optional): confirm the chain accepted the payment.
The service contract lists what we promise and measured performance. There is no SLA yet.
Support: <support contact>.
| Term | Meaning |
|---|---|
| Blob | The bytes you put, 128 KiB − 5 to 128 MiB − 5 |
| Rows | The blob is laid out as 4096 equal rows: a 5-byte header, your data, then zero padding. The network adds 12,288 parity rows |
| Row root | Merkle root over all 16,384 rows |
| RLC | Random linear combination: one 16-byte value per row, with coefficients derived from the row root. It ties every row's content to the commitment |
| Commitment | SHA256(row_root ‖ rlc_root), where rlc_root is the Merkle root of the per-row RLC values |
blob_id |
Version byte 00, then the commitment. 33 bytes, 66 hex characters |
| Receipt | chain_id, tx_hash, blob_id and promise_height from the put response. Only blob_id is needed to read the blob |
| Commitment proof | Two row tree nodes (row_root_siblings) that let you recompute the row root from your own data |
| Item | Value |
|---|---|
| Endpoint | https://cf.celestia-corto.com:8443. Port 8443 only |
| Private endpoint | https://private.cf.celestia-corto.com:8443: the same instances on their private IPs. Only for clients in the gateway's VPC or a peered VPC, in the gateway's Availability Zone, AZ ID euc1-az2 (match the ID, not the AZ name: names differ per account). Same-AZ traffic is free of AWS data-transfer charges. From anywhere else it does not connect |
| DNS | The name round-robins over the instances serving traffic |
| TLS | TLS 1.2 or newer. Public Let's Encrypt certificate for cf.celestia-corto.com and *.cf.celestia-corto.com |
| Chain | corto-10 |
The same certificate and tokens work on every name.
HTTP client settings:
- Keep-alive. Reuse one client and its connection pool. Do not open a connection per request.
- Spread load. Each instance takes at most 48 puts at once (see Limits), so concurrency above that must reach several instances.
A connection stays on the instance it first reached, and a single HTTP/2 connection carries every request to one instance.
Use HTTP/1.1 with one connection per request in flight, and connect each one to a different address from the DNS answer:
system resolvers usually return the addresses in the same order, and most HTTP libraries connect to the first one, so every connection lands on the same instance.
The Rust crate's
Clientdoes this for you. It can also spread HTTP/2 traffic by keeping one connection per instance and sending requests to them in turn (Protocol::Http2), but HTTP/1.1 stays the default: it uploads large blobs a little faster, because HTTP/2's server-controlled flow-control window throttles a single connection's upload. - No automatic replay. Never let your HTTP library resend a put body on its own.
Retry with a new request (see Errors and retries).
The Rust crate's
Clientnever replays aPOST.
Celestia will issue you tokens out-of-band. Reach out via Slack or email if additional tokens are needed.
- Use bearer authorization headers:
Authorization: Bearer <token> 401 unauthorizedmeans the token is missing, wrong or retired, or the header was sent twice.
A put accepts 131,067 through 134,217,723 bytes (128 KiB − 5 through 128 MiB − 5).
The −5 reserves space for Fibre's 5-byte blob header (1 version byte and 4 bytes for the data length)
Other sizes will be rejected with 413.
Fibre encodes and charges every blob as at least 256 KiB (4096 rows × 64-byte minimum row), so blobs below 256 KiB − 5 bytes pay for padding.
The gateway returns exactly the bytes you put. It never strips padding. If your data is smaller than the minimum, pad it yourself and store the real length. If your data is larger than the maximum, split it into chunks and embed metadata to allow reconstruction of the original blob.
| Limit | Value |
|---|---|
| Blob size | 131,067 to 134,217,723 bytes |
| Concurrent puts per instance | 48. We recommend no more than 32 |
| Concurrent gets per instance | 32 |
| Request headers | 16 KiB, sent within 5 s |
| Retention | 24 h after the put |
| Idempotency | None. A retried put may store a second copy |
The put and get limits are per instance, fixed by the gateway, and shared by every token, priority tokens included; no token can raise them.
Fleet-wide capacity is therefore about 48 × active puts (see Capacity), and only if your connections are spread over the instances (see Connect).
A slot is held for the whole request, up to about 155 s for a put. Above the limit the gateway returns 429 capacity at once; it does not queue.
If you need more capacity, reserve it or ask for more instances.
A sudden large job can see 429 until enough capacity is ready.
Before a large job, reserve a minimum number of instances for a while:
| Request | Meaning |
|---|---|
POST /v1/capacity with {"instances":20,"minutes":60} |
Keep at least instances (2–20) up for minutes (1–120) |
GET /v1/capacity |
Current reservation and fleet size |
Both return:
{"floor":20,"expires_at":"2026-10-04T13:00:00+00:00","target":20,"active":8,"running":12,"eta_seconds":150}| Field | Meaning |
|---|---|
floor |
Reserved instances. 0 when no reservation is active |
expires_at |
When the reservation ends, or null |
target |
Instances the fleet is scaling to |
active |
Instances serving traffic now |
running |
Instances started, including ones still warming up |
eta_seconds |
Estimated time until target instances serve traffic |
- Start the job after
eta_seconds, or pollGETuntilactivereachestarget. - There is one reservation for the whole fleet. A new
POSTreplaces it; at most one change per 10 s. - Only tokens allowed to reserve capacity may call it; others get
403 forbidden. Ask us to allow yours. - Capacity calls do not use put or get slots.
| Operation | Gateway deadline | Client timeout to use |
|---|---|---|
| Connect | — | 10 s |
| Put | 150 s, plus 5 s to write the response | 160 s |
| Get | 120 s, plus 5 s to write the response | 130 s |
The put deadline covers receiving the body, upload, chain confirmation and the receipt lookup. The body must arrive in full before the deadline.
The Rust crate's Client uses these timeouts by default. To change them:
use std::time::Duration;
use fibre_gateway_client::{Client, Timeouts};
let timeouts = Timeouts { put: Duration::from_secs(200), ..Timeouts::default() };
let client = Client::with_timeouts(GATEWAY, token, timeouts);Every status code is listed in the API reference.
| Status | Retry? | How |
|---|---|---|
429, 503 |
Yes | Exponential backoff from 1 s, cap 30 s, full jitter, up to 10 attempts |
502, 504 on get |
Yes | Same backoff |
502, 504 on put |
Yes, with a new request | Start at 5 s, same cap. May store a second copy |
| Network error during a put | Yes, with a new request | Same as 502 on put |
400, 401, 404, 413 |
No | Fix the request or token first |
Priority traffic. Under high load an instance may refuse some tokens with 429 priority_only.
Treat it like any other 429, but when the Retry-After header is present wait that many seconds before retrying.
Unknown outcome. After a 502 or 504 on put, or a network error once the body started,
the blob may already be stored and paid for. A retry stores and pays again.
That is fine: keep the receipt you finally get.
502 proof_unavailable means the blob is stored and paid; report it to us.
Blobs can be read for 24 h after the put. After that /v1/get returns 404.
For each blob, store:
| What | Why |
|---|---|
blob_id |
/v1/get takes it as the request body |
| The time of the put | To know when the 24 h ends |
| Your real data length, if you padded | The gateway returns the padded bytes |
With our libraries you do not implement any verification.
- Rust
Client:Client::putchecks the commitment before it returns. There is nothing else to do. - Rust with your own HTTP stack: call
fibre_gateway_client::verify(&data, &put.receipt, &put.commitment_proof). - Python: call
verify(data, put["blob_id"], put["commitment_proof"]["row_root_siblings"])right after the put.
Each check fails if the gateway committed to bytes other than yours. After it passes, keep only the blob_id.
Reads need no check on your side: the gateway checks every piece it downloads against blob_id.
Checking the chain is optional, see below. To write your own verifier, see
the reference verifier in clients/python/fibre_verify.py.
The commitment check shows that blob_id names your data. It does not show that the chain accepted it.
Fetch the transaction from any validator RPC, v0 through v9. They use HTTPS with a
Let's Encrypt certificate; plain http:// is refused.
curl -s "https://v0.cf.celestia-corto.com:26657/tx?hash=0x<tx_hash>"Check, in result.tx_result:
codeis absent or0(CometBFT omits a zero code).- An event with
typecelestia.fibre.v1.EventPayForFibrehas attributecommitmentequal toblob_idwithout its first byte. The value is base64 inside JSON quotes:"value":"\"q83v...=\"". - Optionally, its
signerattribute is the gateway account you expect.
result.tx (base64 protobuf) holds the MsgPayForFibre. Its payment_promise has commitment,
blob_version (0), namespace and blob_size (4096 × row_size) if you want to check those too.
One RPC is one node's view. Query more than one, or use a light client, if that matters. Blobstream will be the trust-minimized way to prove inclusion.
This Rust function, from clients/rust/examples/put_verify_get.rs,
does the check. It needs base64 = "0.22" and serde_json = "1".
use base64::{engine::general_purpose::STANDARD, Engine};
type Error = Box<dyn std::error::Error>;
/// Checks that a CometBFT `/tx` response succeeded and holds an
/// `EventPayForFibre` for `commitment`.
fn check_pay_for_fibre(tx: &serde_json::Value, commitment: &[u8]) -> Result<(), Error> {
let result = &tx["result"]["tx_result"];
// CometBFT omits `code` when it is 0.
if result["code"].as_u64().unwrap_or(0) != 0 {
return Err(format!("tx failed: {tx}").into());
}
let events = result["events"].as_array().ok_or("no events")?;
for event in events {
if event["type"] != "celestia.fibre.v1.EventPayForFibre" {
continue;
}
for attr in event["attributes"].as_array().ok_or("no attributes")? {
if attr["key"] != "commitment" {
continue;
}
// The value is a JSON string holding base64.
let value: String = serde_json::from_str(attr["value"].as_str().ok_or("bad value")?)?;
if STANDARD.decode(value)? == commitment {
return Ok(());
}
}
}
Err("no EventPayForFibre for this commitment".into())
}Call it with &parse_blob_id(&receipt.blob_id)?[1..] as the commitment.
The example runs the whole flow: put, commitment check, on-chain check, get and hash check.
| Language | Where | Commitment check | HTTP client |
|---|---|---|---|
| Rust | clients/rust, crate fibre-gateway-client |
verify |
Client (feature http) |
| Python | clients/python/fibre_verify.py | verify |
Use requests or similar |
Verifying a 128 MiB − 5 blob on one laptop core (Apple M5 Pro) takes about 0.2 s in Rust (23 ms on all 18 cores) and 3.7 s in pure Python. Use Rust in production.
Add it from crates.io:
[dependencies]
fibre-gateway-client = { version = "0.1", features = ["http"] }
bytes = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }Client::new(url, token)builds an async client with the default timeouts. Requests require a Tokio runtime.client.put(data).awaittakesbytes::Bytes, uploads, verifies the commitment on Tokio's blocking pool and returns theReceipt. It fails withHttpError::Verifyif the proof does not match your data.client.get(blob_id).awaitreturns the blob bytes.client.capacity(instances, minutes).awaitreserves capacity;client.capacity_status().awaitreads it.- A non-2xx response is
HttpError::Status(e):e.status, the body'se.code(likecapacityorinvalid_length) ande.message, ande.retry_afterfrom theRetry-Afterheader.codeandmessageareNonewhen the body is not the gateway's JSON, for example an error page from a proxy.err.status()returns the status for any error. - Network and response decoding errors are
HttpError::Request(e), whereeis areqwest::Error; usee.is_timeout()for timeouts. HttpError::Joinmeans the verification task failed to complete.- Without the
httpfeature you get the types andverifyonly, for use with your own HTTP stack.
Examples, run from clients/rust:
TOKEN=<token> cargo run --release --features http --example quickstart -- blob.bin
GW=https://cf.celestia-corto.com:8443 TOKEN=<token> RPC=https://v0.cf.celestia-corto.com:26657 \
cargo run --release --features http --example put_verify_get -- blob.binclients/python/fibre_verify.py
verify(data, blob_id, row_root_siblings) raises VerifyError on a mismatch.
A put and get with requests (pip install requests):
import requests
from fibre_verify import verify # clients/python/fibre_verify.py
BASE = "https://cf.celestia-corto.com:8443"
TOKEN = "<token>"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
def put(data: bytes) -> dict:
assert 131_067 <= len(data) <= 134_217_723
r = requests.post(
f"{BASE}/v1/put", data=data, timeout=(10, 160),
headers={**HEADERS, "Content-Type": "application/octet-stream"},
)
if r.status_code != 200:
raise RuntimeError(f"put: HTTP {r.status_code}: {r.text.strip()}")
put = r.json()
# Raises VerifyError if the receipt does not commit to data.
verify(data, put["blob_id"], put["commitment_proof"]["row_root_siblings"])
return put
def get(blob_id: str) -> bytes:
r = requests.post(
f"{BASE}/v1/get", json={"blob_id": blob_id}, timeout=(10, 130),
headers={**HEADERS, "Content-Type": "application/json"},
)
if r.status_code != 200:
raise RuntimeError(f"get: HTTP {r.status_code}: {r.text.strip()}")
return r.content
data = bytes(128 * 1024 * 1024 - 5) # fill with your payload
blob_id = put(data)["blob_id"] # keep only this
assert get(blob_id) == dataA network error after the put body started sending is an unknown outcome. Retry it with a new request (see Errors and retries).