Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.brew.new/llms.txt

Use this file to discover all available pages before exploring further.

POST endpoints support idempotency via the Idempotency-Key HTTP header. Same key + same body within 24 hours returns the cached original response instead of doing the work twice. Same key + a different body returns 409 IDEMPOTENCY_CONFLICT. Set this on every retried POST.

Contract

PropertyValue
HeaderIdempotency-Key: <≤ 100 chars>
MethodsPOST only. (PATCH is naturally idempotent at the resource level.)
Window24 hours from the first request.
Same key + same bodyReturns the original cached response (same status, same body, same headers).
Same key + different body409 IDEMPOTENCY_CONFLICT.
Different keyTreated as a fresh request.
ScopeNamespaced per-organization (one tenant’s key cannot collide with another’s).

Why every retry needs it

Network errors, timeouts, and re-delivered webhooks all cause your code to call the same API twice. Without idempotency that means duplicate workflow runs, duplicate emails sent, duplicate contact rows created. With Idempotency-Key, the retry replays the original outcome without doing the work again. The endpoints that need it most:
  • POST /v1/automation/runs — fire branch. A doubled fire = doubled welcome / drip / receipt emails.
  • POST /v1/sendsEMAIL_ALREADY_SENT (409) blocks a same-email retry; an idempotency key lets a retry return the original runId cleanly.
  • POST /v1/emails — generate is expensive (~30–90s). Idempotency makes a flaky-network retry free.
  • POST /v1/contacts (single OR batch) — the underlying upsert is naturally idempotent on email, but Idempotency-Key saves you the second batch run.

Body-field fallback on the fire branch

POST /v1/automation/runs (fire branch) additionally honors a body idempotencyKey field for back-compat with the legacy /v1/events route:
{
  "triggerEventId": "tri_signup",
  "payload": { "email": "jane@example.com", "firstName": "Jane" },
  "idempotencyKey": "signup-jane@example.com-1779292800"
}
The HTTP header is preferred for new code; the body field stays for parity. If both are set, the HTTP header wins. The key MUST be stable per logical operation. Common recipes:
OperationRecommended key
Trigger fire from a webhook<eventName>-<userId>-<eventTimestamp> (e.g. signup-user_123-1779292800)
Trigger fire from an internal job<jobName>-<runId>-<targetUserId>
Campaign sendsend-<emailId>-<audienceId>-<scheduledAt or "now">
Email generate from a chatemail-gen-<conversationId>-<turnId>
Batch contact upsertcontacts-batch-<importId>
Cron-driven daily blastdaily-promo-<YYYY-MM-DD>
Anti-patterns to avoid:
  • <uuid()> per call — defeats the purpose; every retry gets a fresh key.
  • Date.now() per call — same as above.
  • <sha256(body)> — vulnerable to partial replays where body bytes change.

Worked example — fire a trigger with retry

const idempotencyKey = `signup-${userId}-${eventTimestamp}`

async function fireWithRetry() {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const res = await fetch('https://brew.new/api/v1/automation/runs', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.BREW_API_KEY!}`,
        'Content-Type': 'application/json',
        // Same key on every attempt — first one wins, the rest replay
        'Idempotency-Key': idempotencyKey,
      },
      body: JSON.stringify({
        triggerEventId: 'tri_signup',
        payload: { email: 'jane@example.com', firstName: 'Jane' },
      }),
    })
    if (res.ok) return res.json()
    if (res.status === 409) {
      const body = await res.json()
      if (body.error?.code === 'IDEMPOTENCY_CONFLICT') {
        // You changed the body between retries. Fix the body OR mint a fresh key.
        throw new Error('Idempotency conflict — fix the body or use a new key')
      }
    }
    if (res.status === 429) {
      const wait = Number(res.headers.get('Retry-After') ?? '1') * 1000
      await new Promise((r) => setTimeout(r, wait))
      continue
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
  }
  throw new Error('Exhausted retries')
}
Two invariants:
  1. Reuse the same idempotencyKey across every retry of the same logical operation. The first request wins; the rest return the cached response.
  2. Mint a fresh idempotencyKey when the body genuinely changes. A new email recipient = a new key.

409 IDEMPOTENCY_CONFLICT envelope

{
  "error": {
    "code": "IDEMPOTENCY_CONFLICT",
    "type": "conflict",
    "message": "Idempotency-Key 'signup-jane@example.com-1779292800' was previously used with a different request body.",
    "suggestion": "Use a fresh key OR submit the exact same body as the original request.",
    "docs": "https://docs.brew.new/api-reference/api/idempotency"
  }
}
The cached original response can be re-fetched by replaying with the same body — there is no separate “get cached result” endpoint.

Where it interacts with other contracts

  • Rate limits — an idempotent replay counts against the rate-limit window. If you saw a 429 on the first attempt, the retry will hit the gate too. Honor Retry-After, don’t burn keys.
  • Workflow runsPOST /v1/automation/runs fire creates rows in automationExecutions. An idempotent replay returns the original automationRunIds[] under details so polling stays consistent.
  • SendsPOST /v1/sends reserves the email atomically via reserveEmailSend. An idempotent replay short-circuits before re-reservation; a fresh key on a same-email send produces 409 EMAIL_ALREADY_SENT.

SDK behaviour

The official @brew.new/sdk ships idempotency keys automatically on every POST call (auto-generated UUID, scoped to the in-process operation) so you get safe-by-default retries. Override the auto-key by passing { idempotencyKey: 'your-key' } in RequestOptions:
await brew.automationRuns.fire(
  {
    triggerEventId: 'tri_signup',
    payload: { email: 'jane@example.com' },
  },
  { idempotencyKey: `signup-${userId}-${eventTimestamp}` }
)

See also

  • Rate limits — pair with idempotency for safe retry loops.
  • Errors — full 409 IDEMPOTENCY_CONFLICT envelope.
  • Async jobs & polling — for fire / send retries you typically don’t need to call again — poll the run instead.

Need Help?

Our team is ready to support you at every step of your journey with Brew. Choose the option that works best for you:

Search Documentation

Type in the “Ask any question” search bar at the top left to instantly find relevant documentation pages.

ChatGPT/Claude Integration

Click “Open in ChatGPT” at the top right of any page to analyze documentation with ChatGPT or Claude for deeper insights.