OIB/1 / PUBLIC INTEGRATION REFERENCE
Broadcast API
IntentScan's OIB/1 channel broadcasts off-chain intent events to filler and solver subscribers. AgentSwap front ends can publish native orders to the same channel before an on-chain announcement. The stream is an optimization, so clients should keep their own on-chain or upstream completeness floor and deduplicate on id.
01 / CONNECTION
Base URL and routes
https://broadcast.intentscan.net
The base URL returns a JSON descriptor for OIB/1 and the service route set. The six integration routes are:
{"protocol":"OIB/1","service":"intentscan-stream","routes":{"publish":"POST /v1/stream/publish","cursor":"GET /v1/stream/intents?since=<seq>","open_book":"GET /v1/stream/open","websocket":"GET /v1/stream/ws","sse":"GET /v1/stream/sse","health":"GET /v1/health"}}
| Method | Path | Use |
|---|---|---|
| POST | /v1/stream/publish | Admit and publish a native AgentSwap order |
| GET | /v1/stream/open | Read the current open book |
| GET | /v1/stream/intents?since=<seq> | Read events after a sequence cursor |
| GET | /v1/stream/ws | Subscribe over WebSocket |
| GET | /v1/stream/sse | Subscribe over Server-Sent Events |
| GET | /v1/health | Check service and on-chain source health |
02 / WIRE FORMAT
The envelope
WebSocket, SSE, cursor, and open-book intent events use the same envelope. Values below are abbreviated for readability. fill is the bearer authorization and settlement payload; the relay cannot forge it. summary is advisory display and filter data only, and must never reach pricing or execution.
{
"event": "intent_open",
"seq": 918273,
"stream_epoch": "01J8Z6R3M4...",
"data": {
"id": "8453:agentswap:0xab10...",
"origin": "native",
"platform": "agentswap",
"chain_id": 8453,
"order_hash": "0xab10...",
"fillable": "permissionless",
"exclusive_until_ms": null,
"fill": {"standard": "agentswap-v5.1", "settler": "0xC0b66...", "order": "0x...", "signature": "0x...", "resolver": null},
"summary": {
"input": {"token": "0x833589...", "amount": "1000000", "decimals": null, "symbol": null},
"output": {"token": "0x420000...", "min_amount": "900000000000000", "decimals": null, "symbol": null},
"created_ms": 1756800000000, "decay_end_ms": 1756800030000, "deadline_ms": 1756800300000
},
"observed_ms": 1756800000123,
"source": "push:native"
}
}
Native publishing currently fills summary.input.decimals, summary.input.symbol, summary.output.decimals, and summary.output.symbol as null.
03 / PRODUCE
Publish
Send a JSON object with exactly these request fields. order is ABI-encoded IntentOrder; its fields are owner, recipient, tokenIn, amountIn, tokenOut, startAmountOut, endAmountOut, startTime, decayEndTime, endTime, appData, and nonce. auth is the authorization bytes. chainId is optional.
{"order":"0x...","auth":"0x...","chainId":8453}
Production serves chain IDs 8453, 42161, 56, and 4663. When chainId is omitted, admission starts with 8453, the default, then searches the remaining served chains in order. Admission includes an eth_call to UserProxyV5.isIntentAuthorized.
Responses
| Status | Meaning |
|---|---|
201 | New order. The body is the published envelope. |
200 | Already published. The body is empty. |
422 | Request or admission verdict: invalid order: <reason>; chain <id> is not served; order has expired; order is cancelled; order is not authorized; admission rejected: <reason>; owner proxy is not deployed; or owner admission rate limit exceeded. |
500 | Service or admission failure: native publish has no served chains configured; admission RPC failed: <reason>; or admission metrics unavailable. |
curl -X POST https://broadcast.intentscan.net/v1/stream/publish \
-H 'content-type: application/json' \
--data '{"order":"0x...","auth":"0x...","chainId":8453}'
04 / SNAPSHOT
Open book
GET /v1/stream/open returns the current open intents and the sequence at which the snapshot was read.
{"seq":918273,"events":[]}
Each item in events is an intent envelope. On a cold start, read this book first, then subscribe with its seq as since.
05 / CATCH-UP
Cursor feed
GET /v1/stream/intents?since=<seq> returns events after the required numeric cursor.
{
"stream_epoch": "01J8Z6R3M4...",
"current_seq": 918280,
"gap": null,
"events": []
}
gap is either null or an object with last_seq. Use the returned stream_epoch with your stored cursor. A cursor gap means the requested history is not fully available.
06 / LIVE
WebSocket
Connect to wss://broadcast.intentscan.net/v1/stream/ws and send a text JSON subscribe frame first. since is optional and replays available history before live frames.
{"op":"subscribe","filters":{"chain_ids":[8453],"fillable":["permissionless"]},"since":918273}
Filters
| Field | Type | Match |
|---|---|---|
chain_ids | array of numbers | Exact chain_id |
platforms | array of strings | Exact platform |
fillable | array | permissionless, exclusive, restricted, or informational |
min_size_usd | number | Minimum input amount divided by 10^decimals. When summary.input.decimals is null, as it is for every natively published order today, the amount normalizes to 0, so any positive minimum excludes those events. |
tokens | array of strings | Input or output token address, case-insensitive |
Frame kinds
Every frame has event, seq, stream_epoch, and data.
event | data |
|---|---|
subscribed | {"filters":{...}} |
intent_open, intent_filled, intent_cancelled, intent_expired | Intent data |
heartbeat | {} |
gap | {"last_seq":...} |
const BASE = "https://broadcast.intentscan.net";
const socket = new WebSocket("wss://broadcast.intentscan.net/v1/stream/ws");
socket.addEventListener("open", () => socket.send(JSON.stringify({
op: "subscribe",
filters: { chain_ids: [8453], fillable: ["permissionless"] },
since: 918273,
})));
socket.addEventListener("message", ({ data }) => {
const frame = JSON.parse(data);
console.log(frame.event, frame.data);
if (frame.event !== "gap") return;
fetch(`${BASE}/v1/stream/intents?since=${frame.data.last_seq}`)
.then((response) => response.json())
.then(console.log);
});
07 / BROWSER
SSE
Connect to GET /v1/stream/sse. Every parameter is optional. since replays available history first. The filters are the WebSocket filters as query parameters, with list values comma-separated: chain_ids, platforms, fillable, tokens, and min_size_usd. A list item that does not parse, such as a non-numeric chain id or an unknown fillable value, answers 400 with a message naming the field. Each SSE data field carries the same JSON frame shape used by WebSocket.
const events = new EventSource(
"https://broadcast.intentscan.net/v1/stream/sse?since=918273&chain_ids=8453,42161&fillable=permissionless"
);
events.onmessage = ({ data }) => {
const frame = JSON.parse(data);
console.log(frame.event, frame.data);
};
08 / CLIENT CONTRACT
Client protocol rules
- Cold start: read
/v1/stream/open, then subscribe with itsseqinsince. - Gap: keep the connection open and backfill with
/v1/stream/intents?since=<last_seq>. A gap is not a reconnect signal. - Epoch: a changed
stream_epochmeans a full re-sync. - Heartbeat: expect one every 20 seconds and reconnect after 3 missed heartbeats.
- Dual path: also run your own
eth_getLogsfloor or upstream poll, and deduplicate onid. This stream is an optimization, never the source of truth. - Pricing: treat
summaryas advisory. Decode and verifyfillbefore pricing or execution.
09 / EDGE POLICY
Limits
| Scope | Limit |
|---|---|
| Reads per IP | 20 requests/second for the open book and cursor feed, with burst 40 and no delay |
| Publish per IP | 5 requests/second, with burst 10 and no delay |
| Concurrent streams per IP | 20 WebSocket or SSE connections |
| Publish body | 256 KiB maximum |
| Native owner admission | Per-owner window, configured per served chain. Over budget returns 422 with owner admission rate limit exceeded. |
| Edge over budget | nginx has no custom limit status, so an over-budget client receives 503. |
The edge allows CORS from any origin, the Content-Type header, and GET, POST, and OPTIONS. Preflight max age is 600 seconds. Health and read timeouts are 10 and 30 seconds, publish read timeout is 15 seconds, and stream read/send timeouts are 3600 seconds.
10 / OPERATIONS
Health
GET /v1/health returns 200 when the store is readable and the on-chain source is running. Otherwise it returns 503 with status: "degraded".
{"status":"ok","store_readable":true,"last_onchain_poll_completed_ms":1756800000123,"onchain_source_status":"running"}
last_onchain_poll_completed_ms is null before the first completed poll. onchain_source_status is running, stopped, or not_polled.