Skip to main content
Kynva rate-limits requests per account. Limits are enforced per endpoint category, not as a single global bucket — so a flood of status polls can’t starve your renders. Limits scale with your plan tier.

Categories and free-tier limits

CategoryEndpointsFree tierScales to (Business+)
RenderPOST /facade/generate10 / min200+ / min
Job statusGET /facade/render-jobs/{id}60 / min600+ / min
Signed imagesGET /api/v1/generate/{template}30 / min300+ / min
Billing/facade/billing/*10 / min50+ / min
Defaulteverything else60 / min600+ / min
Need higher limits? Email support@kynva.ai with your use case. We routinely lift limits for production workloads.

Response headers

Every response includes:
HeaderMeaning
X-RateLimit-LimitCap for this category in the current window.
X-RateLimit-RemainingCalls left in the current window.
X-RateLimit-ResetUnix timestamp (seconds) when the window resets.
When you exceed a limit, the response is 429 Too Many Requests with a Retry-After header.

Hitting the limit

HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests. Please try again later.",
    "details": [
      {
        "message": "Retry after 12 seconds",
        "severity": "error",
        "context": { "retry_after": 12 }
      }
    ]
  }
}

Backoff that works

A robust retry loop:
  1. On 429, read Retry-After. Wait at least that long.
  2. On 5xx, exponential backoff: 1s, 2s, 4s, 8s, with ±20% jitter.
  3. Cap retries at 5. After that, surface the failure.
  4. Always reuse the same Idempotency-Key across retries — see idempotency.
import { randomUUID } from "node:crypto";

async function renderWithBackoff(body: object, attempt = 0): Promise<Response> {
  const key = (globalThis as any).__key ??= randomUUID();

  const res = await fetch("https://api.kynva.ai/api/v1/facade/generate", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.KYNVA_API_KEY!}`,
      "Content-Type": "application/json",
      "Idempotency-Key": key,
    },
    body: JSON.stringify(body),
  });

  if (res.ok || attempt >= 5) return res;

  if (res.status === 429) {
    const wait = Number(res.headers.get("Retry-After") ?? "1") * 1000;
    await new Promise(r => setTimeout(r, wait));
    return renderWithBackoff(body, attempt + 1);
  }

  if (res.status >= 500) {
    const wait = Math.min(2 ** attempt * 1000, 16_000);
    const jitter = wait * (0.8 + Math.random() * 0.4);
    await new Promise(r => setTimeout(r, jitter));
    return renderWithBackoff(body, attempt + 1);
  }

  return res;
}
import os, time, uuid, random, requests

def render_with_backoff(body, attempt=0, key=None):
    key = key or str(uuid.uuid4())

    res = requests.post(
        "https://api.kynva.ai/api/v1/facade/generate",
        headers={
            "Authorization": f"Bearer {os.environ['KYNVA_API_KEY']}",
            "Content-Type": "application/json",
            "Idempotency-Key": key,
        },
        json=body,
        timeout=60,
    )

    if res.ok or attempt >= 5:
        return res

    if res.status_code == 429:
        wait = int(res.headers.get("Retry-After", "1"))
        time.sleep(wait)
        return render_with_backoff(body, attempt + 1, key)

    if res.status_code >= 500:
        base = min(2 ** attempt, 16)
        time.sleep(base * (0.8 + random.random() * 0.4))
        return render_with_backoff(body, attempt + 1, key)

    return res

Tips for staying under the limit

  • Batch when you can. Use POST /facade/render-jobs with multiple briefs instead of N synchronous renders.
  • Cache aggressively. GET responses include strong ETags; respect them.
  • Use the async path. Render jobs don’t block on your client connection — fire-and-subscribe-to-webhook is cheaper for both sides.
  • Pre-resolve brands. List brands once on app boot, not per render.