Skip to content

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/swap and POST /v1/send are 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 human error string.
  • Throttle proactively with X-RateLimit-Remaining / X-RateLimit-Reset instead of waiting for 429.
  • 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.

ts
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-7f3c9a2e

Backoff

ts
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-After when present (429, 503, 504).
  • Otherwise use exponential backoff with jitter.
  • 400 / 404 / 410 are terminal — fix the request or re-quote instead of retrying.

Rate-limit headers

Every metered response — success and 429 — carries:

HeaderMeaning
X-RateLimit-LimitPer-minute quota for the endpoint category
X-RateLimit-RemainingRequests left in the current sliding window
X-RateLimit-ResetEpoch seconds when the window frees up
Retry-After(429/503) seconds to wait

Throttle on X-RateLimit-Remaining before you hit 429:

ts
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:

ts
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