Idempotency

You know when a retry is the same logical operation; we don't. Pass idempotencyKey on a create or update and, for 24 hours, sending the same request again with that key gets the first successful response back, not a duplicate row. A key stands for one request, so the same key with a different body is refused.

How it wires up

Most create and update calls, and sub-resource .create calls, accept an options bag with idempotencyKey. The client adds it to the Idempotency-Key header before sending. Two creates don't take one yet: analytics.csatSurveys.create and community.posts.createComment. The SDK doesn't retry those, so check before you send one again. Updates to members, custom fields and settings (statuses, quick replies, SLA rules, preferences, business hours, the widget) don't take a key either; sending the same update twice leaves the same result.

basic.js
await cstar.tickets.create(
  { title: 'Need help', priority: 'high' },
  { idempotencyKey: 'submission_form_42' }
);

// Same key and same body inside 24h returns the first response. No duplicate ticket.
await cstar.tickets.create(
  { title: 'Need help', priority: 'high' },
  { idempotencyKey: 'submission_form_42' }
);

What the API does with the key

  • Same key, same request: the first successful response comes back, with Idempotent-Replayed: true. A request that failed doesn't keep its key, so fix it and retry with the same one.
  • Same key, different body: 422 idempotency_key_reused (CStarValidationError). Use a new key for a new request.
  • First request still running: 409 idempotency_key_in_use with Retry-After in seconds. With a key, the SDK waits that long and retries within maxRetries (0.18.1 and later); 0.18.0 throws CStarConflictError.
  • Same key on another endpoint: also 409 idempotency_key_in_use, without Retry-After.
  • A request that never finishes (a crashed function) holds its key for up to 15 minutes, answering 409 idempotency_key_in_use meanwhile.
  • Key not recorded: 503 service_unavailable with Retry-After. Nothing ran; retry with the same key.
  • Key length: 1 to 250 characters. A longer key is a 400 with param: Idempotency-Key.
  • File uploads: multipart bodies are compared by their fields and each file's name, type and bytes, so a new form boundary on a retry still counts as the same request.
  • Ignored: creating API keys, webhooks, webhook listeners, event stream tokens, exports and billing portal sessions. A key does not make webhooks.create, webhooks.listen or export.create safe to retry. From 0.18.2 the SDK doesn't send the key on those calls and doesn't retry a network error or 5xx. With 0.18.1 and earlier, leave the key off them (without one the SDK doesn't retry them), or use a client made with maxRetries: 0. Check webhooks.list() before you retry webhooks.create. A repeated export.create only reads your data again (nothing is stored). A repeated webhooks.listen is a second CLI listener: remove it with webhooks.unlisten(id), or it is swept after 5 minutes without a heartbeat.
  • Ticket messages keep their own receipt for the key: a retry returns the saved message, and a different message with that key is a 409 idempotency_key_in_use.

Pick the key like a fingerprint

Make the key deterministic for the logical operation, not the network attempt. Some shapes that work:

  • Form submissions: submission_${formId}_${userId}. Same form, same user, same ticket.
  • Webhook handlers: stripe_${event.id}. Stripe sends the same event ID on every retry.
  • Queue jobs: job_${jobId}. Survives worker restarts and at-least-once delivery.
  • Bad shape: crypto.randomUUID(). A fresh UUID per attempt is the same as no key.

Webhook handler that survives retries

The classic case. Stripe (or any webhook source) retries delivery on non-2xx responses; the idempotency key keeps you safe even when your handler runs twice.

stripe-handler.js
import express from 'express';
import { CStarClient } from '@cstar.help/js';

const cstar = new CStarClient({
  apiKey: process.env.CSTAR_KEY,
  teamId: process.env.CSTAR_TEAM_ID
});

app.post('/stripe-webhook', async (req, res) => {
  const event = req.body;

  await cstar.tickets.create(
    {
      title: `Stripe issue: ${event.type}`,
      priority: 'high',
      // Tags are stored lowercase with _ turned into -,
      // so invoice.payment_failed is saved as invoice.payment-failed.
      tags: ['stripe', event.type]
    },
    { idempotencyKey: `stripe_${event.id}` }
  );

  res.status(200).end();
});

Matching tags later? Compare against the stored form: tags are lowercased, spaces and underscores become a dash, and each is cut to 50 characters.

No key, no dedup

Skip idempotencyKey and the SDK won't retry a create or update after a network error or a 5xx, because the first attempt may already have written. Retrying it yourself without a key can create a duplicate. That's fine for fire-and-forget calls but a footgun for anything that creates a row your customer cares about.

Next up