Reliability & Retries
Everything you need to retry safely against the Venum API: idempotent submission, backoff, the rate-limit header contract, quote expiry, and stream keepalive.
TL;DR
- Retries are safe.
POST /v1/swapandPOST /v1/sendare idempotent on the transaction signature — a duplicate submit replays the original result instead of re-broadcasting. - The signature is always in the response, including on
502. You never have to derive it locally. - Branch on
code, never on the humanerrorstring. - Throttle proactively with
X-RateLimit-Remaining/X-RateLimit-Resetinstead of waiting for429. - Refresh quotes just-in-time using
expiresAt.
Idempotent submission
You submit an already-signed transaction to /v1/swap and /v1/send, so the transaction signature is a perfect idempotency key: deterministic and collision-proof. The server dedups on it automatically.
- A successful submit is cached briefly. A duplicate of the same signed tx within the window returns the original response (with header
Idempotent-Replay: true) and does not re-broadcast. - Concurrent duplicates collapse onto a single submission — a double-tap can't double-send.
- A failed submit is not cached, so a genuine retry actually re-broadcasts and can succeed the second time.
This means the safe-retry recipe is simply: resend the same signed transaction. No need to derive the signature and poll /v1/tx/:sig first.
async function submitWithRetry(signedTxBase64: string) {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch('https://api.venum.dev/v1/send', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': KEY },
body: JSON.stringify({ transaction: signedTxBase64 }),
});
const body = await res.json();
if (res.ok) return body; // { signature, status: 'submitted', ... }
if (body.code === 'SEND_FAILED') { // never entered the network — safe to retry
await backoff(attempt, res);
continue;
}
if (body.code === 'SUBMISSION_FAILED') { // unknown — retry is idempotent on the signature
await backoff(attempt, res);
continue;
}
throw new Error(`${body.code}: ${body.error}`); // 400/404/410 — don't retry blindly
}
}Optional Idempotency-Key header
If you prefer the explicit pattern, send an Idempotency-Key header. When present it becomes the dedup key (scoped to your API key) instead of the signature. Use a fresh key per distinct operation; reusing a key replays the first result for that key.
Idempotency-Key: order-7f3c9a2eBackoff
async function backoff(attempt: number, res: Response) {
const retryAfter = Number(res.headers.get('Retry-After'));
const base = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(250 * 2 ** attempt, 4000); // 250ms → 4s exponential
const jitter = base * 0.2 * Math.random();
await new Promise((r) => setTimeout(r, base + jitter));
}- Honor
Retry-Afterwhen present (429,503,504). - Otherwise use exponential backoff with jitter.
400/404/410are terminal — fix the request or re-quote instead of retrying.
Rate-limit headers
Every metered response — success and 429 — carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Per-minute quota for the endpoint category |
X-RateLimit-Remaining | Requests left in the current sliding window |
X-RateLimit-Reset | Epoch seconds when the window frees up |
Retry-After | (429/503) seconds to wait |
Throttle on X-RateLimit-Remaining before you hit 429:
if (Number(res.headers.get('X-RateLimit-Remaining')) <= 1) {
const reset = Number(res.headers.get('X-RateLimit-Reset'));
await new Promise((r) => setTimeout(r, Math.max(0, reset * 1000 - Date.now())));
}Quote expiry
POST /v1/swap/build returns a top-level expiresAt (epoch milliseconds) — the instant after which the quoteId is gone and /v1/swap returns 410 QUOTE_EXPIRED. The binary (octet-stream) build variant exposes the same value in the x-quote-expires-at header.
Refresh just-in-time instead of guessing the TTL:
const build = await buildSwap(...);
if (Date.now() > build.expiresAt - 1500) {
// within 1.5s of expiry — re-build before asking the user to sign
}A 410 simply means re-build and re-sign; it is not an error in your integration.
Streams (SSE)
The price and tx streams emit a periodic heartbeat event so you can detect a dead connection without waiting on a data event:
event: heartbeat
data: {"ts": 1751000000000}If you don't see a heartbeat (or any event) within ~30s, treat the connection as dead, close it, and reconnect with backoff. On reconnect you receive current state on the next tick — for tx status, re-sync with a single GET /v1/tx/:sig rather than expecting a replay of missed events.
See also
- Error Handling — the full status +
codereference. - Rate Limits — per-tier quotas.
- Transaction Submission — submission channels and landing.
