A backend worker sends a signature request for a customer contract. The HTTP call hangs, the client gives up after its timeout, and the on-call engineer sees one question in the logs: did that request go out or not? The answer decides whether a real person in another company receives zero, one, or three emails asking them to sign.
Most integration guides cover the happy path, then add a line that says "retry with an idempotency key." That advice is incomplete for consequential mutations: sending a signature request, voiding one, resending after an amendment, or finalizing a record into your archive. A timeout does not tell you the request failed. It tells you that you stopped waiting. The outcome is unknown, and unknown is a state of its own: completion could not be confirmed, which is neither success nor verified failure.
This article is written for backend leads, solution architects, and on-call engineers who own an eSignature integration. It proposes a reconcile-first operation ledger: a design you can adapt, not an established eSignly method. It covers durable tenant-scoped operation identity, immutable payload hashes, atomic work claims, webhook acknowledgment, terminal failure, uncertain input, and privacy ownership. For the fundamentals of keys, retries, and signature verification, see our existing guide on idempotency, retries, and webhook resilience. This piece starts where that one stops: the moment you cannot tell what happened.
Key Takeaways
- ⏱️ A timeout is an unknown outcome. Record it as such, and do not retry blindly until you have checked authoritative operation state.
- 🔑 An idempotency key alone is not enough. Bind a durable, tenant-scoped operation identity to a hash of the immutable approved payload, and reject key reuse with a changed payload.
- 🔒 Claim work atomically so two workers cannot submit the same operation. A read-then-write check is not compare-and-swap.
- 📥 Acknowledge a webhook only after the event is verified, deduplicated, durably recorded, and its follow-up work is scheduled, in one atomic step or an explicitly recoverable one.
- 🧯 Define terminal failure and uncertain-input handling before launch: quarantine what you cannot interpret, and never let silence become success.
- 🛡️ Identifiers such as signer emails and request IDs can be sensitive data. Assign an owner for logging, retention, and deletion.
- ✅ Use the go/no-go controls in the last section as all-required checks. They are not a score and they do not certify production readiness.
Why an Ambiguous Timeout Is Not the Same as a Failure
Key takeaway: A client-side timeout proves only that your wait budget ended. The provider may have rejected the request, accepted it, or still be processing it. Treat the result as unknown and resolve it with evidence.
Distributed systems textbooks describe this as the two generals problem, but the practical version is simpler. Your HTTP client closed the connection. The server may have already committed the work. The response may have been lost on the way back. A proxy in the middle may have dropped it. From your side, all three look identical.
The HTTP specification is explicit about the risk. RFC 9110 on HTTP semantics defines which methods are idempotent and notes that POST is not among them. It advises clients not to automatically retry a non-idempotent request unless they know its semantics are effectively idempotent or have some means to detect that the original request was never applied. Note the second condition. The standard describes detecting that the first attempt did not happen. That is reconciliation, and it comes before the retry.
What each observation actually proves
| What your client observed | What it proves | What it does not prove | Safe default |
|---|---|---|---|
| 2xx response with a provider reference | The provider accepted the operation | That your local database recorded it | Persist the reference, mark confirmed |
| 4xx validation or authorization rejection | This request, as sent, was refused | That a different earlier attempt did not succeed | Terminal failure for this payload; fix input, do not loop |
| Rate-limit or conflict response | The provider declined to process now | Anything about earlier attempts | Retry with backoff using the same operation identity |
| 5xx response | The provider or a gateway had a problem | That no side effect occurred | Unknown until reconciled |
| Timeout or connection reset | Your wait budget ended | Whether the request was applied | Unknown; reconcile before any resubmission |
| 2xx with unparsable or truncated body | A response of some kind was produced | Which operation it refers to | Unknown; reconcile |
Why signature operations raise the stakes
A duplicated database row is annoying. A duplicated signature request leaves your system and lands with an external party. Consider the consequences of acting on a wrong guess:
- Duplicate send: a counterparty receives two requests for the same agreement and may sign the wrong one, or ask which is operative.
- Missed send: your system believes a request is out, so nobody follows up, and a deadline passes quietly.
- Wrong void: a retried cancellation can invalidate a request that was legitimately re-issued after an amendment.
- Split evidence: two provider records exist for one business event, and your audit trail has to explain both. Our guide to architecting esignature audit trails covers why that explanation matters later.
None of this is a legal conclusion. Whether a duplicate or misdirected request creates any legal consequence depends on your agreement, jurisdiction, and facts, so involve counsel for contracts where the answer matters. The engineering goal is narrower: never let an uncertain state silently turn into a business decision.
How Most Teams Handle It, and Why That Breaks
Key takeaway: The common patterns each fix one failure and open another. Blind retry duplicates, key-only retry assumes provider behavior you may not have verified, and "treat timeout as failure" invites a second click.
Teams rarely choose a poor strategy deliberately. They inherit one from a generic HTTP client library, a queue's default retry policy, or a pattern that worked for idempotent reads. The table compares four approaches for consequential mutations.
Decision artifact: four ways to handle a timed-out mutation
| Approach | What it protects | Hidden failure mode | Build effort | Acceptable for |
|---|---|---|---|---|
| A. Blind retry on timeout | Transient network blips on reads | Duplicate sends when the first attempt had succeeded | Minimal | Idempotent reads only |
| B. Retry with the same idempotency key | Duplicate creation, if the provider honors the key | Depends on provider scope, key retention window, and how it treats a changed payload; says nothing about your own double-claim bugs | Low | Low-stakes creates after you have verified provider semantics |
| C. Treat timeout as failure; let the user retry | Simplicity | User clicks again while the first request is live; support tickets about duplicates | Minimal | Internal drafts, never external sends |
| D. Reconcile first, then retry under one operation identity (proposed) | Duplicate, missed, and conflicting operations | More state to operate; needs a provider lookup path or a documented escalation | Moderate | Any send, void, resend, or finalize |
Why idempotency keys are necessary but not sufficient
Idempotency keys are the right tool, and you should use them where the provider supports them. Stripe's documentation on idempotent requests is a useful public reference for the pattern. As a vendor statement, it describes saving the result of the first request for a key, returning that result on repeats, and returning an error if the same key arrives with different parameters. The same documentation notes that keys can be pruned after a period. That detail matters here: a retry that arrives after the provider has forgotten the key looks like a brand new request.
Those semantics are specific to one vendor. Do not assume any eSignature API behaves the same way. Some eSignly developer guides describe idempotency headers, and our guide to state sync and API reliability covers the surrounding patterns, but header names, supported endpoints, and retention windows must be confirmed in the current eSignly API reference for the endpoint you call. If the reference does not document idempotency for an operation, design as if it does not exist.
Even with provider support, a key does not solve four problems that live on your side of the wire:
- Two of your workers can both decide to submit before either records a key.
- A developer can reuse a key for an edited payload and silently get the original operation back.
- A late retry can outlive the provider's key retention.
- A webhook handler that returns success before recording the event loses the event with no retry.
2026 update, framed for the long run
A standard Idempotency-Key header has been proposed in the IETF. As of the most recent revision we reviewed, the Internet-Draft was listed as expired, with its latest revision dated October 2025. An expired draft is a work in progress, not a standard, and its own boilerplate says drafts should not be cited as reference material other than as work in progress. The durable lesson does not depend on any header: treat provider idempotency as a convenience layer you verify per endpoint, and keep your own durable record of intent as the source of truth.
A Proposed Design: The Reconcile-First Operation Ledger
Key takeaway: Record intent durably before calling the provider. Give every consequential action one operation identity, one approved payload hash, and an explicit state machine that includes an UNKNOWN state.
The following is a proposed design for teams to adapt. It is not a certified pattern, and it is not a description of how any specific eSignly feature works internally. It rests on five rules.
- Intent before action. Write the operation record, including the payload hash and a provider-facing key, before the first network call.
- Identity is tenant-scoped. Every lookup, uniqueness constraint, and queue message carries the tenant. Two tenants can never collide on an operation.
- Payload is immutable once approved. The hash binds the identity to exactly what a person or policy approved.
- Unknown is a first-class state. It is reached on timeout, 5xx, or an unparsable response, and it can only be left through reconciliation.
- Work is claimed atomically. A single conditional update decides which worker acts, and everyone else backs off.
The operation state machine
| State | Meaning | Entered when | Allowed next states |
|---|---|---|---|
| PLANNED | Approved and recorded; not yet submitted | Operation created | IN_FLIGHT, SUPERSEDED |
| IN_FLIGHT | One worker holds a lease and is calling the provider | Atomic claim succeeds | CONFIRMED, TERMINAL_FAILED, UNKNOWN |
| UNKNOWN | Completion could not be confirmed | Timeout, 5xx, bad response body, or lease expired mid-call | RECONCILING |
| RECONCILING | One worker is checking authoritative state | Atomic claim on UNKNOWN | CONFIRMED, PLANNED (proven absent), UNKNOWN (inconclusive), NEEDS_REVIEW |
| CONFIRMED | Provider reference stored; operation is real | 2xx with reference, or reconciliation found it | Terminal for this operation |
| TERMINAL_FAILED | Provider definitively refused this payload | Validation or permission rejection | Terminal; a corrected payload is a new operation |
| NEEDS_REVIEW | Evidence conflicts, for example duplicates found | Reconciliation sees more than one match | Human decision, then CONFIRMED or SUPERSEDED |
The control flow in plain steps
- The business workflow approves a payload. Your service canonicalizes it, hashes it, and inserts a ledger row in PLANNED. Insert and any outbox message commit together.
- A worker atomically claims the row. If the claim returns zero rows, it stops.
- The worker calls the provider with the persisted provider key and a bounded timeout.
- A 2xx stores the provider reference and moves to CONFIRMED. A definitive rejection moves to TERMINAL_FAILED. Anything else moves to UNKNOWN.
- A reconciler claims UNKNOWN rows, asks the provider for authoritative state, and applies the decision table in the next section.
- Only a proven-absent result returns the row to PLANNED for resubmission under the same operation identity and key.
Teams running the eSignly integrations or building on the API can map these states onto their own datastore. The shape is vendor-neutral, which is intentional: your ledger should outlive any single provider choice.
Building It: Identity, Payload Hash, Atomic Claim, and Reconciliation
Key takeaway: Uniqueness on (tenant, intent) prevents double creation, the payload hash prevents silent substitution, and a conditional UPDATE decides who owns the work. Code below is illustrative. It is not an official eSignly API contract.
1. Durable, tenant-scoped identity bound to an immutable payload
The business intent key describes what the business meant to do, for example "send revision 3 of contract C-1042 for signature." It should come from your workflow, not from the retry layer. The provider-facing key is a separate opaque value, generated once and stored before the first attempt. Keep it free of personal data.
The payload hash should cover everything that changes what the signer receives or what the workflow means: document content digest (not file name), signer identities and order, field placement, expiration and reminder settings, and message text. Exclude volatile values such as timestamps and request IDs. Use a deterministic serialization so the same approved content always hashes the same.
-- Illustrative schema (PostgreSQL-style) CREATE TABLE signing_operation ( tenant_id text NOT NULL, operation_id uuid NOT NULL, intent_key text NOT NULL, -- e.g. contract id + revision + action action text NOT NULL, -- SEND, VOID, RESEND, FINALIZE payload_hash bytea NOT NULL, -- hash of canonical approved payload provider_key uuid NOT NULL, -- opaque; persisted before first attempt provider_ref text, -- set only when confirmed state text NOT NULL, attempt_no int NOT NULL DEFAULT 0, lease_owner text, lease_expires_at timestamptz, PRIMARY KEY (tenant_id, operation_id), UNIQUE (tenant_id, intent_key) ); -- Create or fetch. A conflict is resolved by comparing hashes in application code. INSERT INTO signing_operation (tenant_id, operation_id, intent_key, action, payload_hash, provider_key, state) VALUES (:tenant, :op, :intent, :action, :hash, :pkey, 'PLANNED') ON CONFLICT (tenant_id, intent_key) DO NOTHING; -- If zero rows inserted: SELECT the existing row. -- same payload_hash -> return the existing operation (a true duplicate request) -- different payload_hash -> REJECT. Never reuse an intent or key for a changed payload.Rejecting a changed payload is the point. If a user edits a contract after approval, the edit is not a retry. It is a different operation with its own identity, discussed below.
2. Atomic claim, not read-compare-then-write
A pattern that reads the state, checks it in application code, and then writes is a race. Two workers can both read PLANNED and both proceed. The claim must be a single conditional mutation that the database evaluates atomically, with the outcome read from the affected row count.
-- Claim for submit: exactly one worker can win UPDATE signing_operation SET state = 'IN_FLIGHT', lease_owner = :worker, lease_expires_at = now() + interval '2 minutes', attempt_no = attempt_no + 1 WHERE tenant_id = :tenant AND operation_id = :op AND payload_hash = :hash AND state = 'PLANNED'; -- rows affected = 1 -> you own it; 0 -> stop, someone else does or state moved on. -- Sweeper: an expired lease means the outcome is unknown, not that the call failed UPDATE signing_operation SET state = 'UNKNOWN', lease_owner = NULL WHERE state = 'IN_FLIGHT' AND lease_expires_at < now();The sweeper rule is easy to miss. A worker that crashes mid-call may have sent the request. Returning the row to PLANNED would cause exactly the duplicate you are trying to prevent. If your datastore lacks conditional updates, disclose that limitation and serialize through a single-writer queue per tenant, accepting the throughput cost.
3. Worker behavior around the provider call
# Illustrative pseudo-code; function names are placeholders, not an eSignly SDK def submit(tenant_id, operation_id): op = ledger.claim_for_submit(tenant_id, operation_id, worker_id) if op is None: return # another worker owns it, or state moved on payload = approved_payloads.load(tenant_id, op.payload_ref) if canonical_hash(payload) != op.payload_hash: ledger.needs_review(tenant_id, operation_id, 'payload changed after approval') return try: resp = provider.create_signature_request( payload, idempotency_key=op.provider_key, timeout_seconds=30) except (TimeoutError, ConnectionResetError): ledger.mark_unknown(tenant_id, operation_id) # not success, not failure schedule_reconcile(tenant_id, operation_id) return if resp.ok and resp.provider_ref: ledger.confirm(tenant_id, operation_id, resp.provider_ref) elif resp.is_definitive_rejection: # validation or permission refusal ledger.terminal_fail(tenant_id, operation_id, resp.error_code) else: ledger.mark_unknown(tenant_id, operation_id) # 5xx, truncated body, etc. schedule_reconcile(tenant_id, operation_id)4. Reconciliation: decide with evidence
Reconciliation asks the provider's authoritative record what exists, using the strongest identifier available. That identifier might be a provider reference you already stored, or a correlation value you embedded in the request if the API supports one. Confirm in the API reference which lookup paths exist. If there is no way to query by your reference or list recent requests, you cannot prove absence, and the safe choices narrow to relying on verified provider-side idempotency, escalating to a human, or accepting a duplicate and voiding the extra.
| Reconciliation result | Interpretation | Action |
|---|---|---|
| Exactly one matching request found | The first attempt succeeded | Store provider reference, move to CONFIRMED. Do not resubmit. |
| No match, and lookup is authoritative and past the quiescence window | Absence is supported by evidence | Return to PLANNED; resubmit with the same operation identity and key |
| No match, but the original call may still be in flight or lookup is eventually consistent | Absence is not proven | Stay UNKNOWN; re-check after a longer delay |
| Lookup itself fails or times out | Still unknown | Stay UNKNOWN; back off; alert if age exceeds your threshold |
| More than one match | A duplicate exists | NEEDS_REVIEW; a human or policy chooses which to keep and voids the rest through a new, ledgered VOID operation |
Note that a VOID is itself a consequential mutation. It gets its own operation identity, its own payload hash, and its own reconciliation path.
5. Retry versus a legitimate later operation
The ledger must tell "the same thing again" apart from "a new thing." The rule: a retry reuses the operation; a new business decision creates a new operation.
| Situation | Same operation? | Why |
|---|---|---|
| Worker timed out; same approved payload | Yes | Same intent, same hash |
| Queue redelivers the same message | Yes | Claim fails; no second submit |
| Contract amended, new revision approved | No | New payload hash and new revision in intent key |
| Signer email corrected by an authorized user | No | Changes who receives the request; needs fresh approval |
| Deliberate resend after the signer reports a lost email | No | A distinct RESEND action decided by a person or policy |
| Same payload sent again by a different user by mistake | Yes, rejected as duplicate | Intent key already exists with the same hash |
If an amendment supersedes an earlier request, mark the earlier operation SUPERSEDED in the same transaction that creates the new one, and queue a ledgered VOID for the old provider request.
Webhooks: Verified Acknowledgment, Terminal Failure, and Uncertain Input
Key takeaway: A 2xx response to a webhook is a promise that you will not need the event again. Give it only after the event is verified, deduplicated, durably recorded, and its work is scheduled. Anything you cannot interpret goes to quarantine, not to the void.
The ledger covers calls you make. Webhooks cover state changes you did not initiate, such as a signer completing or declining. The eSignly integrations page states that you configure a webhook URL in your account settings and that eSignly sends a POST request with a JSON payload when events such as sent, viewed, signed, or declined occur. Confirm the exact event names, signature scheme, retry schedule, and event identifier fields in the current API reference before you build your handler. Our deeper walkthrough of webhook architecture and event integrity covers the transport details.
What "verified acknowledgment" means
Responding 200 "quickly" is common advice, and it is incomplete. Speed is useful only if the response is honest. Acknowledge only after all of these are true:
- The request authenticated against the verification mechanism the provider documents.
- The tenant was derived from the verified endpoint or secret mapping, not from a field inside the unverified body.
- The event identifier was inserted into a uniqueness-constrained inbox, or was found to be already present.
- The follow-up work item was written in the same transaction, or an explicitly recoverable mechanism guarantees it will exist.
-- Illustrative: dedupe + durable inbox + work scheduling in ONE transaction BEGIN; INSERT INTO webhook_inbox (tenant_id, provider_event_id, received_at, body_ref, status) VALUES (:tenant, :event_id, now(), :body_ref, 'RECEIVED') ON CONFLICT (tenant_id, provider_event_id) DO NOTHING; -- Only when the inbox insert actually inserted a row: INSERT INTO work_item (tenant_id, kind, subject_ref, dedupe_key, run_after) VALUES (:tenant, 'APPLY_SIGNING_EVENT', :provider_ref, :event_id, now()) ON CONFLICT (tenant_id, dedupe_key) DO NOTHING; COMMIT; -- Return 2xx only after COMMIT succeeds. If the transaction fails, return 5xx -- so the provider's delivery mechanism, if documented, can try again.If the provider does not supply a stable event identifier, deduplication on derived fields is weaker. Say so in your runbook, and prefer fetching authoritative state after each event rather than trusting the payload alone.
Treat the webhook as a signal, apply state with conditional mutation
Events can arrive late, twice, or out of order. When a worker applies one, do not read local state, compare in code, and then write. Push the condition into the write itself so the datastore arbitrates:
UPDATE contract_signing SET status = 'COMPLETED', completed_at = :event_time WHERE tenant_id = :tenant AND provider_ref = :ref AND status IN ('SENT', 'PARTIALLY_SIGNED'); -- rows affected = 1 -> applied -- rows affected = 0 -> inspect current status: -- COMPLETED -> duplicate or late event, record as no-op -- VOIDED or DECLINED -> conflicting history, route to NEEDS_REVIEW -- row missing -> uncertain input, quarantineThis covers your own database. The provider side has its own limits: if its API offers no conditional mutation, such as an entity-version check on void, a race between your void and a signer completing is decided on the provider's side. Disclose that in your design and reconcile afterward.
Three rules for the unhappy paths
| Condition | Classification | Handling |
|---|---|---|
| Signature or authentication check fails | Untrusted input | Reject; log minimal security metadata; never create business state |
| Verified event, unknown event type or unmapped provider reference | Uncertain input | Record in inbox as QUARANTINED, alert, and acknowledge only because it is durably stored for review. Do not drop or guess. |
| Verified event that repeatedly fails to apply | Terminal failure for that work item | Dead-letter after a bounded number of attempts, retain the raw event, open an owned ticket, and keep reconciling against provider state |
Terminal failure needs an owner and a clock. A dead-letter queue nobody reads is just a slower way to lose events. Name the team responsible, set an alert on age, and give them a way to replay safely, through the same conditional updates, so a replay cannot corrupt state.
The same discipline applies to automated callers. If an internal tool, workflow engine, or AI agent can trigger sends or voids, route it through the ledger instead of letting it call the provider directly. An automated caller that retries aggressively is the fastest way to generate duplicates.
Why This Fails in the Real World
Key takeaway: Experienced teams still fail here because the dangerous states are rare, the controls span several owners, and each piece looks correct in isolation. The scenarios below are illustrative composites of common patterns, not customer results.
Failure pattern 1: The correct retry that was correct for the wrong layer
A platform team configures its HTTP client to retry timeouts three times with backoff, which is a sensible default. A separate product team's job queue also retries failed jobs. A send request times out at the client, the client retries, and the first attempt turns out to have succeeded, so the provider holds two requests. Each layer behaved reasonably. Nobody owned the question "what is the identity of this operation across layers?" The system gap is that retries were configured per library, while operation identity belonged to nobody. The fix is structural: exactly one layer retries consequential calls, and it does so only through the ledger.
Failure pattern 2: The key that outlived its payload
A developer generates an idempotency key from the contract ID, which seems natural and stable. A user then edits the signer list and re-submits. Depending on provider semantics, the call is either rejected as a key conflict or, worse, returns the original request's result, so the team's system believes the new signer list is out when the old one is. Careful engineers still miss this because the key looked deterministic, and deterministic feels safe. The root cause is that identity was bound to the business object rather than to an approved payload. Binding the key to a payload hash, and rejecting reuse with a different hash, closes it.
Failure pattern 3: Success acknowledged, work lost
A webhook handler validates the signature, returns 200 to stay under a latency budget, and then enqueues work to an in-memory queue. A deploy restarts the process before the worker runs. The provider saw success and will not redeliver. The contract stays "sent" in your system forever, and the signer waits for a follow-up that never comes. The team had load tests and dashboards. What it lacked was an explicit definition of what a 2xx promises. The remedy is the single-transaction inbox plus work item shown earlier, with an age alert on anything still RECEIVED.
Failure pattern 4: The unknown state with no owner
A reconciler exists, but when the lookup is inconclusive it quietly retries forever, or worse, flips the row back to PLANNED after a fixed delay to "unstick" it. During a provider degradation, hundreds of unknown rows get resubmitted at once. This happens because an alert on stuck state feels like noise and a timer feels like a solution. Make inconclusive reconciliation escalate to a named owner, and make the return to PLANNED conditional on evidence, not elapsed time.
Why smart teams repeat these
- The failure window is small, so staging and load tests rarely hit it.
- Ownership is split between platform, product, and compliance, so nobody sees the whole path.
- Dashboards show request success rates, not unresolved operations.
- Provider documentation describes the happy path and leaves edge semantics to inference.
Go/No-Go Controls, Privacy Ownership, and Trade-offs
Key takeaway: Use the controls below as an all-required gate. If any one is missing, the answer is no-go for consequential mutations, regardless of how many others pass. This list helps you find gaps. It does not certify production readiness or legal compliance.
Mandatory go/no-go controls
| # | Control | Evidence to show |
|---|---|---|
| 1 | Operation intent is written durably before any provider call | Ledger row exists with payload hash prior to first attempt |
| 2 | Operation identity and uniqueness are tenant-scoped | Unique constraint includes tenant; cross-tenant test fails closed |
| 3 | Key reuse with a changed payload is rejected | Automated test: same intent, different hash, expect rejection |
| 4 | Work is claimed by atomic conditional mutation | Concurrency test with parallel workers: one claim wins |
| 5 | Timeouts and ambiguous responses land in UNKNOWN | Fault-injection test, including lost response after provider success |
| 6 | No resubmission occurs before reconciliation | Code path review: only reconciled-absent rows return to PLANNED |
| 7 | Provider idempotency behavior is verified, not assumed | Written note citing the current API reference for each endpoint, or a statement that it is unsupported |
| 8 | Webhook ack follows verification, dedupe, durable record, and scheduling | Crash test between receipt and processing loses no event |
| 9 | Terminal failure and quarantine have owners and age alerts | Named team, runbook, alert thresholds |
| 10 | State changes use target-side conditional writes | Rows-affected handling for duplicate, late, and conflicting events |
| 11 | Legitimate later operations get new identities | Amendment, correction, and resend tests produce new operations |
| 12 | Privacy ownership for identifiers is assigned | Documented owner, retention rule, and log redaction policy |
Identifiers can themselves be sensitive
It is tempting to treat IDs as safe because they are not names. They often are not. Signer email addresses, provider references that map to a specific person and agreement, IP addresses in audit data, and even the existence of a signing request can reveal a business relationship or a health, employment, or financial matter. The OWASP Logging Cheat Sheet advises against recording sensitive data in logs and recommends considering what to remove, mask, or hash. Apply that to this design:
- Log the operation ID and tenant, not signer emails or document names.
- Keep payload hashes in the ledger. If a hash could be guessed from low-entropy inputs, use a keyed hash in anywhere it is exposed outside the ledger.
- Store raw webhook bodies in an access-controlled store with a retention period, and reference them from the inbox rather than copying them into logs.
- Decide who owns deletion requests. Ledger rows may be part of evidence you need to keep, while copies in logs and queues usually are not. Counsel and your privacy lead should decide retention, since obligations vary by jurisdiction, contract, and industry.
Vendor security statements are a separate question from your obligations. eSignly publishes its position on security and compliance; read those pages for the scope of each certification or attestation and request the underlying reports where relevant. A provider's controls do not discharge your own duties for how your ledger, logs, and queues handle personal data.
Trade-offs to accept with eyes open
| Cost of the design | What you get in return | Mitigation |
|---|---|---|
| More state and a reconciler to operate | No blind duplicates; explicit unknown handling | Start with SEND and VOID; add actions gradually |
| Slower recovery when lookup is inconclusive | Evidence-based resubmission | Escalation path and a human review queue |
| Possible duplicates remain if provider has no lookup and no idempotency | Honest disclosure instead of false safety | Void-on-duplicate policy, user messaging, ledgered VOID |
| Per-tenant serialization if no conditional writes | Correctness over throughput | Partition by tenant, scale horizontally |
What to do next, based on your gaps
- Fewer than all controls today: pick SEND, add the ledger row and the atomic claim, and ship that alone first. Controls 1 through 6 give the largest reduction in duplicate risk.
- Controls 1 through 6 in place: work on webhook inbox atomicity and the quarantine path.
- All twelve with evidence: run a game day. Kill a worker mid-call, drop a response, and replay a day of webhooks. The test is whether the ledger explains every outcome.
Building on an eSignature API and want fewer 2 a.m. surprises?
Start with a free account, read the API reference, and test your timeout and webhook paths in a sandbox-style workflow before real signers are involved.
Try eSignly's account and API plans and validate each control against the current documentation.
Start FreeConclusion: Make Unknown a State You Can Explain
Reliable eSignature integrations are rarely lost on the happy path. They are lost in the minute after a timeout, when a system quietly converts "we do not know" into "it worked" or "it failed." The design in this article gives that minute a structure. Five concrete actions to take this quarter:
- Inventory your consequential mutations. List every place your systems send, void, resend, correct, or finalize a signing request, including workflow engines and automated tools. Identify which layer retries each one today.
- Add an operation ledger for SEND first. Persist intent, tenant, and payload hash before the first call, and use a conditional UPDATE for claims.
- Introduce an UNKNOWN state and a reconciler. Forbid resubmission until reconciliation proves absence, and write down what you will do if the provider offers no lookup.
- Fix webhook acknowledgment semantics. Make verify, dedupe, record, and schedule a single transaction or a documented recoverable sequence, with quarantine and dead-letter ownership.
- Assign privacy ownership. Decide who owns identifiers in logs, queues, and the ledger, and verify provider behavior against the current API reference rather than assumptions.
The reconcile-first ledger is a proposed design. Adapt it to your datastore, your provider's documented behavior, and your counsel's guidance, and test it with fault injection before you rely on it.
About eSignly
eSignly has provided online electronic signature software and API services since 2014, serving businesses in the United States, EMEA, and Australia. The company states that it maintains ISO 27001, SOC 2 Type II, HIPAA, GDPR, PCI DSS, and 21 CFR Part 11 compliance programs; the scope of each is described on its security page and compliance page, and your own obligations depend on your use case and jurisdiction. This article was reviewed by the eSignly Expert Team. It is general technical guidance, not legal advice.
Frequently Asked Questions
Is it ever safe to retry an eSignature API request after a timeout?
It can be safe, but only after you have either verified that the provider treats the retry as the same operation or confirmed through an authoritative lookup that the first attempt was never applied. For consequential mutations such as sending or voiding, mark the operation unknown, reconcile its state, and then resubmit under the same operation identity. Blind retry is acceptable for idempotent reads.
Does an idempotency key remove the need to reconcile?
No. A key helps only if the provider supports it for that endpoint, retains it long enough, and handles changed payloads in a way you have verified. It does not stop two of your workers from both submitting, and it does not help when a retry arrives after the key has been pruned. Confirm behavior in the current API reference, and keep your own durable ledger.
What should happen if the same operation key arrives with a different payload?
Reject it. Bind the operation identity to a hash of the immutable approved payload and return a conflict to the caller. A changed payload is a new business decision, so it needs a new operation, fresh approval, and, if it replaces an earlier request, an explicit ledgered void of the old one.
What does an unknown result mean, and how is it different from failure?
An unknown result means completion could not be confirmed. It is not success and it is not verified failure. Typical causes are timeouts, 5xx responses, truncated bodies, and workers that crash mid-call. The only way out of unknown is reconciliation against authoritative state, or human review if no lookup exists.
When should a webhook handler return a success response?
Return 2xx only after the request is authenticated, the tenant is derived from a trusted mapping, the event is deduplicated and durably recorded, and the follow-up work is scheduled atomically or by an explicitly recoverable mechanism. If recording fails, return an error so the provider's documented delivery behavior can try again. Check the API reference for retry details.
How should we handle webhook events we cannot interpret?
Authenticate first. If authentication fails, reject and create no business state. If it passes but the event type or reference is unknown, store the raw event in a quarantine status, alert an owner, and do not guess. Acknowledge it only because it is durably stored for review.
Are signer emails and request IDs sensitive?
They can be. Emails identify people, and a request reference can reveal a relationship or transaction when combined with other data. Minimize them in logs, use operation IDs for tracing, restrict access to raw payloads, and assign an owner for retention and deletion. Specific obligations depend on your jurisdiction and contracts, so consult counsel.
Do the go/no-go controls prove my integration is production-ready or compliant?
No. They are all-required checks for one risk area, ambiguous outcomes on consequential mutations. Passing them does not certify production readiness, security, or legal compliance. Combine them with your security review, load testing, provider due diligence, and legal advice.
Ready to design signing workflows that stay explainable under failure?
Explore eSignly's API, account, and on-premises options, then test your retry, reconciliation, and webhook paths against the current API reference.
Create a free eSignly account and see how your integration behaves before real signers depend on it.
Free TrialResource Esignature Api Guide
This article is most relevant for CTOs and developers who need to roll out a practical signing workflow. Use the related eSignly path to compare plans, API options, compliance fit, and implementation next steps.
Reviewed for electronic signature decision makers
This guide is reviewed for clarity, legal and operational relevance, service alignment, and practical conversion path before being connected to an eSignly plan or API workflow.
For regulated, high-volume, or customer-facing workflows, validate legal duties, plan assumptions, and integration requirements with your internal stakeholders before rollout.

