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
| Endpoint | Idempotency-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 (fromfile, or fetched fromfile_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')
}