log in
consulting hosting industries the daily tools about contact

DocuSign Webhooks vs. Polling: The Delivery Gap That Bites You

DocuSign's envelope status webhooks look simple until you get duplicate events and double-process a completed contract. Here's the gap and how I close it.

DocuSign's Connect webhook system looks straightforward until you've shipped it to production and watched it fire the same envelope-completed event twice within 400 milliseconds, triggering two contract activation emails to your client's customer and provisioning their account twice. That happened to me on a real estate SaaS project three years ago and it took an embarrassing support ticket to unwind. The fix isn't complicated, but DocuSign doesn't surface it prominently, and most of the example code you'll find online ignores it entirely.

What the Envelope Status Webhook Actually Does

DocuSign calls their webhook system "Connect." You configure a listener URL, choose which envelope events you care about (sent, delivered, completed, declined, voided, etc.), and DocuSign POSTs a JSON or XML payload to your endpoint whenever an envelope transitions between states.

The appeal is obvious. Instead of polling the Envelopes API every N minutes asking "is this thing signed yet?", you get pushed a notification the moment something changes. For a completed envelope you might then pull the signed PDF, update a database record, fire off a downstream workflow — whatever your business logic requires.

The problem is that Connect is designed for at-least-once delivery, not exactly-once. DocuSign retries failed deliveries on a backoff schedule. If your endpoint returns anything other than a 2xx, they'll try again. If your endpoint returns a 2xx but does so slowly, they may still retry in some configurations. And occasionally — not constantly, but enough to hurt you — you get a duplicate even when your endpoint responded correctly the first time. Network gremlins, internal DocuSign retry logic, whatever. The envelope ID and event type are identical. Your code doesn't know it's seen this before unless you've built that in.

Polling, meanwhile, has its own failure mode: you can miss state transitions entirely if an envelope moves through two states between polls (rare but possible), and you're burning API quota on envelopes that haven't moved.

The right answer is webhooks plus idempotent processing. Not webhooks as a replacement for sanity checks.

The Delivery Gap in Practice

Here's the timing sequence that burned me:

  1. Signer completes envelope at T+0
  2. DocuSign fires webhook to my endpoint at T+0.1s
  3. My endpoint receives it, starts processing (slow DB write, external API call)
  4. Processing takes 3.2 seconds
  5. DocuSign's Connect timeout fires at T+3s, decides delivery failed
  6. DocuSign retries at T+3.1s
  7. My endpoint receives the retry, processes again
  8. Original request finally commits at T+3.3s

Two completed processing runs for the same envelope. DocuSign's default connect timeout is configurable but often set low. If your handler does any real work synchronously, you are in this failure zone.

The second delivery gap is subtler: even when timeouts aren't involved, DocuSign's documentation acknowledges that Connect events are not guaranteed to arrive in order, and not guaranteed to arrive exactly once. Plan accordingly.

The Pattern I Now Use for Every DocuSign Integration

The fix is two things working together:

  1. Respond 200 immediately. Do zero business logic in the webhook handler itself. Just validate, queue the job, return 200.
  2. Make the queued job idempotent using the envelope ID and event type as a uniqueness key.

Here's what that looks like in Laravel:

<?php

// routes/api.php
Route::post('/webhooks/docusign', [DocuSignWebhookController::class, 'handle']);
<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessDocuSignEnvelopeEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class DocuSignWebhookController extends Controller
{
    public function handle(Request $request): \Illuminate\Http\Response
    {
        // Validate the HMAC signature from DocuSign Connect
        if (! $this->validSignature($request)) {
            Log::warning('DocuSign webhook: invalid signature', [
                'ip' => $request->ip(),
            ]);
            // Still return 200 — returning 4xx causes retries
            // which flood your logs. Log it, move on.
            return response('', 200);
        }

        $payload = $request->json()->all();

        $envelopeId = data_get($payload, 'envelopeId')
            ?? data_get($payload, 'data.envelopeId');

        $event = data_get($payload, 'event');

        if (! $envelopeId || ! $event) {
            Log::error('DocuSign webhook: missing envelope ID or event', $payload);
            return response('', 200);
        }

        // Dispatch and forget. The job handles idempotency.
        ProcessDocuSignEnvelopeEvent::dispatch($envelopeId, $event, $payload);

        return response('', 200);
    }

    private function validSignature(Request $request): bool
    {
        $secret = config('services.docusign.connect_secret');
        if (! $secret) {
            return true; // dev/test environments without Connect configured
        }

        $signature = $request->header('X-DocuSign-Signature-1');
        $body = $request->getContent();
        $computed = base64_encode(hash_hmac('sha256', $body, $secret, true));

        return hash_equals($computed, (string) $signature);
    }
}

Now the job, with idempotency built in via a database lock table:

<?php

namespace App\Jobs;

use App\Models\DocuSignEvent;
use App\Models\SignedContract;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ProcessDocuSignEnvelopeEvent implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 30;

    public function __construct(
        private readonly string $envelopeId,
        private readonly string $event,
        private readonly array $payload,
    ) {}

    public function handle(): void
    {
        // Idempotency check: one row per envelope+event combination.
        // insertOrIgnore returns 0 affected rows if the record exists.
        $inserted = DB::table('docusign_processed_events')->insertOrIgnore([
            'envelope_id' => $this->envelopeId,
            'event'       => $this->event,
            'processed_at' => now(),
        ]);

        if ($inserted === 0) {
            Log::info('DocuSign: duplicate event ignored', [
                'envelope_id' => $this->envelopeId,
                'event'       => $this->event,
            ]);
            return;
        }

        match ($this->event) {
            'envelope-completed' => $this->handleCompleted(),
            'envelope-declined'  => $this->handleDeclined(),
            'envelope-voided'    => $this->handleVoided(),
            default => Log::debug('DocuSign: unhandled event', [
                'event' => $this->event,
            ]),
        };
    }

    private function handleCompleted(): void
    {
        // Pull the signed document via the API — don't trust the webhook
        // payload alone for document content. Verify status server-side.
        $contract = SignedContract::where('docusign_envelope_id', $this->envelopeId)
            ->firstOrFail();

        $contract->update(['status' => 'completed', 'completed_at' => now()]);

        // Kick off whatever downstream logic your business needs.
        // ProvisionAccount::dispatch($contract);
    }

    private function handleDeclined(): void
    {
        // ...
    }

    private function handleVoided(): void
    {
        // ...
    }
}

The migration for that idempotency table:

Schema::create('docusign_processed_events', function (Blueprint $table) {
    $table->id();
    $table->string('envelope_id', 36);
    $table->string('event', 64);
    $table->timestamp('processed_at');
    $table->unique(['envelope_id', 'event']); // This is the actual guard
});

The insertOrIgnore + unique constraint is the whole trick. Under concurrent duplicate delivery (two webhook firings landing at the same millisecond), the database unique constraint ensures exactly one job proceeds. No Redis locks needed, no first_or_create race condition — the DB does the heavy lifting.

Gotchas That Will Still Find You

The event name format changed. Older Connect configurations use envelope-completed. Newer JSON payloads may give you envelope_completed (underscore) or just completed depending on how your Connect subscription is configured and whether you're on the legacy or current payload schema. Log the raw payload early in development and don't assume.

Returning non-2xx for invalid signatures causes retry storms. I know it feels wrong to return 200 on a bad signature, but returning 400 or 401 tells DocuSign to retry, which means a misconfigured secret floods your logs with the same payload forever. Log the failure, return 200, and fix your secret.

The webhook payload doesn't contain the signed PDF. It tells you the status changed. You still have to call the Envelopes Documents API to pull the actual signed document. I've seen integrations that assume the payload has everything they need and skip the document fetch entirely. They work fine until a client asks where their signed PDF is.

Out-of-order events are real. You can receive envelope-completed before envelope-delivered in some edge cases. If your state machine is strict (only allow completed if current state is delivered), you'll need to handle this. I generally store the raw event log and let the state machine be lenient on transitions from unexpected states, or use the server-side status fetch as the authoritative source rather than trusting event ordering.

When I'd Use This Pattern (and When I Wouldn't)

I use webhooks plus idempotent queue processing for any DocuSign integration where the completion event triggers downstream business logic — provisioning, billing, notifications, document archival. That's almost every integration I've built.

I keep a lightweight polling fallback for anything where a missed webhook is catastrophic and the volume is low enough to poll. For a healthcare client where a missing consent form signature would block a procedure, I run a nightly reconciliation job that queries DocuSign for any envelope marked pending in my DB that's been outstanding more than 12 hours and checks the actual current status. This catches the rare case where Connect was misconfigured or the endpoint was down during delivery and the retry window expired.

Pure polling without webhooks I'd only do for batch-style workflows where near-real-time doesn't matter, or in a development environment where I can't expose a public URL for Connect. Otherwise the latency and quota costs aren't worth it.

The Bottom Line

DocuSign Connect is good enough for production — I've run it reliably for years across multiple clients. But it's an at-least-once system, and if you treat it like exactly-once, eventually production will teach you otherwise. Respond fast, queue the work, guard with a unique constraint, and reconcile occasionally. That's the full pattern.

Need help shipping something like this? Get in touch.