Skip to main content

Watch an intent

POST /v1/relay answers at the commit point, once the relayer has co-signed. The transaction is not in a block yet. After it returns you hold an intent ID and a state, and you follow it from there.

WayShapeBest for
GET /v1/relay/{id}/eventsServer-sent events, one per state changeOne transaction, live
client.waitForIntent()Polling with a terminal-state ruleOne transaction, without streaming
GET /v1/intents/{sender}/{nonce}One authoritative read"What happened to that one?"
GET /v1/streamEverything for one accountA server following many

All of the code below is in the site's runnable example watch-intent.ts, tested against a stand-in API that streams in uneven chunks the way a real network does.

One decision, from the state​

watch-intent.ts
export type Outcome =
/** Executed and final. The Relay Units are charged. */
| { readonly kind: 'executed'; readonly intent: Intent }
/** Executed and reverted on chain. Charged too: the network charged the relayer the full gas. */
| { readonly kind: 'failed'; readonly intent: Intent }
/** Another transaction took the nonce. Nothing charged; read your nonce and decide afresh. */
| { readonly kind: 'dead'; readonly intent: Intent }
/** Never got past validation; nothing was co-signed. The problem document says what to fix. */
| { readonly kind: 'rejected'; readonly intent: Intent }
/** Not decided yet. This includes EXPIRED_LOCALLY_STILL_VALID, which can still execute. */
| { readonly kind: 'pending'; readonly intent: Intent };

export function outcomeOf(intent: Intent): Outcome {
switch (intent.state) {
case 'EXECUTED_OK':
return intent.final ? { kind: 'executed', intent } : { kind: 'pending', intent };
case 'EXECUTED_FAIL':
return intent.final ? { kind: 'failed', intent } : { kind: 'pending', intent };
case 'DEAD':
return { kind: 'dead', intent };
case 'REJECTED':
return { kind: 'rejected', intent };
default:
return { kind: 'pending', intent };
}
}
StateChargedWhat to do
EXECUTED_OK, finalyesDone.
EXECUTED_FAIL, finalyesIt reverted on chain. The network charges the relayer the full gas limit for a failed execution, so the units are spent. Read gasUsed and the contract's own error.
DEADnoAnother transaction took the nonce, and the reservation is released. Read your account nonce and decide afresh.
REJECTEDnoIt never got past validation; nothing was co-signed. The problem document says what to fix.
EXPIRED_LOCALLY_STILL_VALIDreservedNot final. CoRelayer stopped re-broadcasting, but the transaction is still valid and may execute. Wait, send the same bytes again, or cancel or replace it with a pinned nonce. Do not sign a replacement as if it had failed. (The intent lifecycle)

An executed state becomes the outcome only once final is true. A block can still be reverted before finality, and finality is what triggers billing.

Polling, with the rule built in​

watch-intent.ts
/** Polls the authoritative read until the intent is decided or `timeoutMs` passes. */
export async function followIntent(
client: CoRelayerClient,
sender: string,
nonce: number,
options: { readonly timeoutMs?: number; readonly onState?: (intent: Intent) => void } = {},
): Promise<Outcome> {
const intent = await client.waitForIntent(sender, nonce, {
intervalMs: 1_000,
timeoutMs: options.timeoutMs ?? 120_000,
...(options.onState === undefined ? {} : { onUpdate: options.onState }),
});
return outcomeOf(intent);
}

waitForIntent stops at a terminal state: EXECUTED_OK, EXECUTED_FAIL, DEAD or REJECTED. An executed intent can still have final: false, which is why outcomeOf reports it as pending. Without onError, a failed read is thrown, so a failure never looks like a pending intent. At the time limit it returns the intent as it stands.

With onError, a failed read is tried again after the milliseconds onError returns, cut short at the time limit. The time limit ends a run of failed reads too: the wait then returns the last intent it read, or throws the last error if no read succeeded. Pass a signal to stop the wait early, including while it waits between reads.

Every API host gives the same answer to GET /v1/intents/{sender}/{nonce}, including a host that did not take your submission. It is the right call after a timeout, a crash or a restart. client.getRelay(id) resolves either an intent id (erd1…:41) or a 64-character transaction hash, if all you kept was the hash.

The per-intent stream​

client.streamRelay(intentId) opens this stream as an EventStream, which handles every line ending the standard allows. It does not reconnect by itself: to resume after a dropped connection, open it again with lastEventId set to the stream's lastEventId (see Event streams). Without the client, a reader for this one stream is a few lines:

watch-intent.ts
/**
* Reads the per-intent server-sent-events stream: one `intent` event per state change, each
* carrying the full `Intent`. The server closes the stream after the first final event, after
* `DEAD` or `REJECTED`, or after 120,000 ms, and the loop then ends.
*/
export async function* streamIntent(
apiBase: string,
intentId: string,
options: { readonly fetch?: typeof globalThis.fetch; readonly signal?: AbortSignal } = {},
): AsyncGenerator<Intent> {
const fetchImpl = options.fetch ?? globalThis.fetch;
const response = await fetchImpl(
`${apiBase.replace(/\/$/, '')}/v1/relay/${encodeURIComponent(intentId)}/events`,
{
headers: { accept: 'text/event-stream' },
...(options.signal === undefined ? {} : { signal: options.signal }),
},
);
if (!response.ok || response.body === null) {
throw new Error(`Event stream for ${intentId} answered ${response.status}.`);
}

// Server-sent events: frames separated by a blank line, `field: value` lines inside a frame,
// `:` lines are keep-alive comments. It uses a reader loop, because not every browser can iterate
// a ReadableStream with `for await`.
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) return;
buffer += decoder.decode(value, { stream: true }).replaceAll('\r\n', '\n');
let end = buffer.indexOf('\n\n');
while (end !== -1) {
const frame = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
end = buffer.indexOf('\n\n');

let event = 'message';
const data: string[] = [];
for (const line of frame.split('\n')) {
if (line.startsWith(':')) continue;
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
const content = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '');
if (field === 'event') event = content;
if (field === 'data') data.push(content);
}
if (event === 'intent' && data.length > 0) yield JSON.parse(data.join('\n')) as Intent;
}
}
}
import { streamIntent } from './watch-intent.ts';

export async function show(intentId: string, render: (state: string) => void): Promise<void> {
for await (const intent of streamIntent('https://api.co-relayer.com', intentId)) {
render(intent.state);
}
}

You can read the stream without credentials. When it closes, fall back to followIntent. It never sends RECEIVED, because it can only be opened once an intent ID exists.

In a browser, EventSource reads the same stream: new EventSource(url) and addEventListener('intent', …).

What an intent looks like​

An intent looks like this (example values):

Intent
{
"intentId": "erd1…:41",
"sender": "erd1…",
"nonce": 41,
"txHash": "…",
"relayer": "erd1…",
"state": "EXECUTED_OK",
"final": true,
"ru": 1,
"receivedAtMs": 1789819200000,
"cosignedAtMs": 1789819200031,
"broadcastAtMs": 1789819200062,
"executedBlockTsMs": 1789819200600,
"includedInBlock": { "shard": 1, "nonce": 12345678 },
"acks": [{ "gateway": "G1", "ms": 31 }],
"latency": { "acceptToCosignMs": 31, "addedMs": 62, "inclusionMs": 600, "roundsToInclusion": 1 },
"feePaidAtto": "100000000000000",
"gasUsed": 100000
}

Some members appear only where they apply: deadReason, stuckReason, corrected (reconciliation turned a DEAD into an executed state) and explorerUrl. advisory appears on EXPIRED_LOCALLY_STILL_VALID and explains that state. What every latency member measures is on Telemetry.

For many transactions at once​

One stream per transaction does not scale. Use the account stream, GET /v1/stream: notices, intent changes and quota movements for one account, on one connection. From a server, authenticate with a native-auth header or a read-scoped key. From a browser, first get a single-use ticket from POST /v1/stream/tickets with the account and topics. EventSource cannot send a header, and a token must never travel in a URL.

The account stream is the way to follow many transactions from a server. This deployment does not deliver webhooks or e-mail: registering a webhook endpoint answers 503 with reason: WEBHOOK_DELIVERY_NOT_AVAILABLE, and no e-mail is sent.