Idempotency

Safely retry network blips without double-sending. Stripe-style Idempotency-Key with a pending lock for concurrent retries.

The two POSTs that create and send something can be safely retried by passing an Idempotency-Key header. Same key + same body returns the cached response; same key + different body returns 422; concurrent retries serialize on the first one's outcome.

curl -X POST https://api.wesign.now/v1/signing-requests \
  -H "Authorization: Bearer $WSK_KEY" \
  -H "Idempotency-Key: txn-7d4f3c-2026-04-30" \
  -F "file=@contract.pdf" \
  -F 'signers=[{"email":"…","role":"…"}]'

Where it applies

EndpointIdempotency-Key
POST /v1/signing-requests✓ — the whole send: document, signing requests, invitation emails, webhook. Replays the 201 — callback.secret included.
POST /v1/templates/{id}/instantiate✓ — same helper, same semantics; replays the direct 200 or the staged 201. A validate_only dry run never reads the header and is never cached.
POST /v1/documents/{id}/confirm✗ header, but retry-safe by design: a second confirm answers 200 with already_confirmed: true (and, for an e-sign instance, recipients: []) and sends nothing.
POST /v1/templates/{id}/generate✗ — the default call only streams a PDF and stores nothing; a repeated review: true call stages a second document.
PATCH /v1/signing-requests/{id}✗ — naturally idempotent: the same number again answers 200 and changes nothing.
POST /v1/signing-requests/{id}/remind✗ — every accepted call sends another email. Guard retries on your side.
POST /v1/signing-requests/{id}/withdraw✗ — but naturally safe to repeat: the second call answers 409 invalid_state and changes nothing.
POST /v1/documents/{id}/discard✗ — safe to repeat: for an e-sign instance a repeat answers 200 with already_discarded: true; a file-only instance is deleted by the first call, so a repeat answers 404 not_found. Neither sends anything.
POST /v1/hooks, POST /v1/hooks/{id}/rotate, POST /v1/embedded/sign-sessions✗ — each call creates a new hook, secret or session.
GET …Not needed.

The header is read only by the two endpoints marked ✓; anywhere else it is ignored, not rejected.

The classic deliverability story: your worker calls our API, the HTTP request hits a transient network error mid-response, the worker retries — and now the customer gets two signing emails. With an idempotency key, the retry replays our cached response instead.

How it works

The full lifecycle of a key:

First call

We compute a SHA-256 over the request and INSERT a pending row keyed by (workspace, key). Then we run the request. What is hashed:

  • POST /v1/signing-requests — the PDF bytes (from file, or fetched from file_url — a replay re-fetches the URL first) plus the recognised body fields: signers, placement, fields, observer_emails, callback_url, signing_mode, locale, expires_in_days, placement_assignee_email, file_url, filename, metadata, send_emails. Unknown top-level keys are not part of it.
  • instantiate — the JSON body exactly as sent, key order included. Serialise the retry byte-for-byte like the first call.

The key's scope is the workspace, across endpoints, and the hash does not include the URL. So never reuse a key for a different template: the same key with the same body on another template replays the first template's response.

Success → cache

We update the row to status='success' with the response status + body. Subsequent retries with the same key + body replay the cached response identically — same JSON, same status code, plus an Idempotent-Replayed: true header and the RateLimit-* headers. A replay still costs one request of the rate limit. Only a 2xx is cached: any error releases the key, so a corrected retry with the same key runs fresh.

Concurrent retry while still in flight

A second call with the same key arrives before the first finishes. We return 409 idempotency_in_progress with a Retry-After header — the seconds until the first call's lock turns stale (at most 60). The caller waits, then re-tries — by then the first call normally finished and the cache hit replays its response.

Same key, different body

You sent two different requests with the same key. We return 422 idempotency_key_reuse so the misroute is loud instead of silent — generate a fresh key per logical request. The hash is compared first, so this answer comes even while the first call is still running. After 2026-09-24 the hash of POST /v1/signing-requests also covers metadata, send_emails and the multipart filename when you send them (before, all three were dropped before hashing), so a retry across that release with the same key and one of them answers 422.

Stale lock recovery

If the first call's worker crashed mid-flight and never finished, the pending row sits there. After 60 s the next retry takes over — clears the lock, runs the request, finalises the cache. No human intervention.

The takeover cannot tell a crashed call from a slow one. A call that is still running after 60 s — a large template render can take longer — and a retry that arrives after that both run. Keep your client's timeout above the time your slowest call takes, and on a timeout retry only after the Retry-After of a 409, not in a tight loop.

Key format

Up to 255 ASCII-printable characters. Anything goes — UUIDs, ULIDs, content hashes, your-system's request IDs. Recommended:

  • Stable across retries — generate ONCE per logical request, use the same value for every retry attempt.
  • Unique per logical request — never reuse a key for a new send, even if it's the "same content".

Lifetime

Cached rows live for 24 hours from the first call. After that they're pruned (every 5 minutes) and a retry past 24 h runs the request fresh — a second document. If you're building a long-running queue, do not retry items older than a day blind: the first webhook for a document (signing_request.sent, or template_instance.staged) carries your metadata, so match it against your own record first.

What's stored

The cache stores: response status, response body, request hash, and a locked_at timestamp. We do not store the request body itself — just its hash, for the "same key + different body" check.

Retry-aware client

async function postWithRetry(opts: { url: string; body: FormData; key: string }) {
  for (let attempt = 1; attempt <= 5; attempt++) {
    const res = await fetch(opts.url, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.WSK_KEY!}`,
        'Idempotency-Key': opts.key,
      },
      body: opts.body,
    })

    if (res.status === 409) {
      // idempotency_in_progress — backoff per Retry-After
      const wait = Number(res.headers.get('retry-after')) || 5
      await new Promise(r => setTimeout(r, wait * 1000))
      continue
    }
    if (res.status === 429) {
      // rate_limited — backoff per Retry-After
      const wait = Number(res.headers.get('retry-after')) || 60
      await new Promise(r => setTimeout(r, wait * 1000))
      continue
    }
    return res // success or non-retryable error
  }
  throw new Error('Exhausted idempotent retries')
}