InsightSocial API

Production checklist

Timeouts, retries, idempotency, pagination, rate limits, caching, keys, budgets and logging before you ship.

Route every call through one server-side wrapper that handles the key, timeouts, retries and logging. Keep per-endpoint choices, like the timeout, as arguments so each call site can set its own.

Start with one request wrapper

Node.js
const BASE_URL = "https://api.insightsocial.app";
const BASE_BACKOFF_MS = 500;
const MAX_BACKOFF_MS = 8_000;

// error.type values worth retrying. Everything else is a permanent answer.
const RETRYABLE = new Set([
  "RATE_LIMITED",
  "CONCURRENCY_LIMIT",
  "IDEMPOTENCY_IN_PROGRESS",
  "SERVICE_UNAVAILABLE",
  "UPSTREAM_ERROR",
  "INTERNAL_ERROR",
]);
const RETRYABLE_STATUS = new Set([429, 500, 503]);

interface Envelope<T> {
  success: boolean;
  data?: T;
  pagination?: { next_cursor: string | null; has_more: boolean; page_size: number };
  error?: { type: string; message: string };
  request_id: string;
  credits_used: number;
  credits_remaining: number | null;
  cached?: boolean;
  idempotent_replay?: boolean;
  charge_reason?: "miss" | "shared_cache" | "owned" | "replay" | "no_result";
  free_call?: boolean;
}

interface Attempt {
  endpoint: string;
  attempt: number;
  status: number | null;
  errorType: string | null;
  requestId: string | null;
  creditsUsed: number | null;
  chargeReason: string | null;
  latencyMs: number;
}

export class InsightSocialError extends Error {
  constructor(message: string, readonly attempt: Attempt) {
    super(message);
    this.name = "InsightSocialError";
  }
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

function backoff(attempt: number, retryAfterMs = 0) {
  const cap = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attempt);
  return Math.max(retryAfterMs, Math.random() * cap); // full jitter, never below Retry-After
}

export async function callApi<T>(opts: {
  endpoint: `/v1/${string}`;
  query?: Record<string, string>;
  attemptTimeoutMs: number;
  maxRetries?: number;
  onAttempt?: (a: Attempt) => void;
}): Promise<Envelope<T>> {
  const key = process.env.INSIGHTSOCIAL_API_KEY;
  if (!key) throw new Error("INSIGHTSOCIAL_API_KEY is not set");

  const url = new URL(opts.endpoint, BASE_URL);
  for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);

  const maxRetries = opts.maxRetries ?? 3;
  const idempotencyKey = crypto.randomUUID(); // one per logical call, reused on every retry

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const started = performance.now();
    const log = (a: Omit<Attempt, "endpoint" | "attempt" | "latencyMs">): Attempt => {
      const entry = {
        endpoint: opts.endpoint,
        attempt: attempt + 1,
        latencyMs: Math.round(performance.now() - started),
        ...a,
      };
      try { opts.onAttempt?.(entry); } catch { /* logging must not change the outcome */ }
      return entry;
    };

    let res: Response;
    try {
      res = await fetch(url, {
        headers: { "x-api-key": key, "Idempotency-Key": idempotencyKey },
        signal: AbortSignal.timeout(opts.attemptTimeoutMs),
      });
    } catch (cause) {
      const entry = log({ status: null, errorType: "NETWORK_OR_TIMEOUT", requestId: null, creditsUsed: null, chargeReason: null });
      if (attempt < maxRetries) { await sleep(backoff(attempt)); continue; }
      throw new InsightSocialError(String(cause), entry);
    }

    const retryAfter = Number(res.headers.get("retry-after"));
    const retryAfterMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : undefined;

    let body: Envelope<T> | undefined;
    try { body = (await res.json()) as Envelope<T>; } catch { body = undefined; }

    const entry = log({
      status: res.status,
      errorType: body?.error?.type ?? (body ? null : "INVALID_RESPONSE"),
      requestId: body?.request_id ?? res.headers.get("x-request-id"),
      creditsUsed: body?.credits_used ?? null,
      chargeReason: body?.charge_reason ?? null,
    });

    if (res.ok && body?.success) return body;

    const retryable = body?.error
      ? RETRYABLE.has(body.error.type)
      : RETRYABLE_STATUS.has(res.status);
    if (!retryable || attempt === maxRetries) {
      throw new InsightSocialError(body?.error?.message ?? `HTTP ${res.status}`, entry);
    }
    await sleep(backoff(attempt, retryAfterMs));
  }
  throw new Error("unreachable");
}

const profile = await callApi({
  endpoint: "/v1/instagram/profile",
  query: { handle: "natgeo" },
  attemptTimeoutMs: 90_000,
  onAttempt: (a) => console.info("insightsocial_attempt", a),
});
console.log(profile.request_id, profile.credits_used, profile.charge_reason);

The key is read from the server environment only. Each attempt reports request_id, the charge and the charge reason to your logs. See Authentication and Response schema.

Set timeouts per endpoint

Pass a per-attempt timeout on every call. When a source is slow we retry it ourselves before answering, so one call can take a minute or more in the worst case. A timeout much shorter than that turns slow successes into failures and retries.

Start from your own measurements of uncached calls, per endpoint. A single profile, a feed page, a metered search with max_pages and a transcript all behave differently. Total wall time for a logical call is the attempts plus backoff plus any Retry-After waits.

If you abandon an attempt, the call is not charged. See the idempotency caveat below before you retry it.

Retry only transient failures

Retry these error.type values: RATE_LIMITED, CONCURRENCY_LIMIT, IDEMPOTENCY_IN_PROGRESS, SERVICE_UNAVAILABLE, UPSTREAM_ERROR, INTERNAL_ERROR. Honor Retry-After when it is present, otherwise back off exponentially with full jitter, and cap the number of attempts.

Do not retry answers that will not change: MISSING_API_KEY, INVALID_API_KEY, API_KEY_REVOKED, INSUFFICIENT_CREDITS, UNKNOWN_PLATFORM, UNKNOWN_ENDPOINT, METHOD_NOT_SUPPORTED, parameter errors such as INVALID_REQUEST, and RESOURCE_NOT_FOUND, which means nothing exists at the identifier you sent (not charged). Branch on error.type, never on the message text. See Error handling.

Errors are never charged, so a retry costs only time and rate-limit room.

Make retries safe

Generate one Idempotency-Key per logical call and send the same value on every retry of that call.

  • The key may be up to 255 characters of letters, digits, ., _, : and -. A key outside that format is ignored, not rejected, so the retry is treated as a new call.
  • Keys are scoped to your account.
  • A replay of a call we already answered comes back with idempotent_replay: true, charge_reason: "replay" and credits_used: 0.
  • A retry that arrives while the first attempt is still running gets 409 IDEMPOTENCY_IN_PROGRESS with Retry-After. Wait and send it again.
  • Do not reuse a key for a new page, a changed parameter or a different endpoint. Reusing a key with a different request can be rejected.

An abandoned first attempt is not a free retry

If we never answered the first attempt successfully, for example because your client timed it out, the retry with the same key that delivers the data is charged as a normal call (charge_reason: "miss") at exactly the ceiling the call reserved: the listed price, or the top of the range for a metered endpoint, multiplied by max_pages if you sent it. Like any charged call, it can be covered by one of your free calls. It costs 0 if you already own that exact call or if the replay returns nothing.

Drain every page

For a list, send pagination.next_cursor back unchanged as cursor and stop when pagination.has_more is false. Do not treat an empty page or a count as the end.

Pages are sequential. Give each page its own idempotency key, and keep that key for the page's retries. On a retry, resend the same cursor. See Pagination.

Control rate and concurrency

Each key allows 60 calls per rolling minute and 10 calls in flight. They fail differently: RATE_LIMITED means you started too many calls in the window, CONCURRENCY_LIMIT means too many are still running. Both are 429 with Retry-After, and neither is charged.

Keep your worker pool and your call rate below both limits, and apply backpressure before adding retries, since retries use the same budget. There are no rate-limit headers to read, so track your own counts. See Rate limits.

Decide when cached data is acceptable

A response with cached: true came from our shared cache and costs 5 credits. A repeat of a call you already paid for inside its window costs 0 (charge_reason: "owned"). Neither is a guarantee: the entry may not exist when you call.

Send Cache-Control: no-cache (or fresh=1) only where your application needs data as of now. It is always charged at the endpoint's full price. Take the window for each endpoint from its reference page rather than hard-coding one. See Caching.

Separate keys by environment

Create separate keys for production, staging, CI and local development, and store each in a server-side secret store. You can hold up to 25 keys. Revoking one does not touch the others, and each key has its own rate and concurrency limits.

Keys have no scopes: every key reaches every endpoint. And every key draws on the same account balance, so a separate key isolates revocation and rate limits, not spend.

Budget your spend

List every endpoint you will call in production and its expected volume, and price it with Endpoint pricing. For metered endpoints, budget the ceiling; dry_run=1 returns an estimate at no cost where it is supported.

A call whose ceiling your balance cannot cover fails with 402 INSUFFICIENT_CREDITS before it runs, and nothing is charged. Read credits_remaining from each response, or poll GET /v1/credits (free), and alert on your own threshold. Remember that exports on the same account draw on the same balance. See Credits.

Log what support needs

For every attempt, record:

  • request_id (also the X-Request-Id header)
  • the endpoint and HTTP status
  • error.type on failures
  • credits_used and charge_reason
  • cached
  • your measured latency and the attempt number

Never log the API key or full sensitive request data. When you report a problem, include the request_id values involved.

Deployment checklist

  • The production endpoint set and required parameters are fixed.
  • Each endpoint has a tested per-attempt timeout.
  • Only the transient error.type values are retried, with a cap, Retry-After, and jittered backoff.
  • Each logical call has its own Idempotency-Key, reused only for its retries.
  • Pagination stops on has_more: false and sends the cursor back unchanged.
  • Call rate and in-flight calls are both bounded below the per-key limits.
  • Each endpoint is marked as accepting cached data or requiring a fresh fetch.
  • The production key is loaded from a server-side secret store and used nowhere else.
  • Expected spend is budgeted from current prices, with an alert on credits_remaining.
  • request_id, status, error type, credits, charge reason, latency and attempt number are logged.
  • A clean pre-production run passed, following Testing your integration.

Next steps

On this page