Webhooks

Listen for document events in your own system — created, sent, signed, completed. Event payloads, signature verification, retry behavior, and the patterns that survive production.

Last updated May 12, 2026

Webhooks deliver document events to your server in near-real-time: document signed, completed, rejected, recipient opened the email, etc. Use them to update your CRM, trigger downstream workflows, or just keep your own database in sync without polling our API.

Quick start

  1. Pick a URL on your server that can accept POST requests
  2. In Pacta → Team Settings → Webhooks → + New webhook
  3. Paste your URL
  4. Select the events you want to receive
  5. Copy the signing secret (whsec_...) — you’ll use it to verify deliveries
  6. Pacta now POSTs to your URL for each matching event

Available events

Fourteen event types, grouped by domain:

Document lifecycle

EventWhen it fires
DOCUMENT_CREATEDA new document is created (via UI or API)
DOCUMENT_SENTThe document is sent for signature
DOCUMENT_OPENEDA recipient opens the signing email
DOCUMENT_SIGNEDA recipient applies their signature (still waiting on others)
DOCUMENT_RECIPIENT_COMPLETEDA specific recipient finishes their part
DOCUMENT_COMPLETEDAll recipients have signed; document is sealed
DOCUMENT_REJECTEDA recipient explicitly declines
DOCUMENT_CANCELLEDThe sender cancels the envelope
DOCUMENT_REMINDER_SENTPacta auto-sends a reminder to a non-responsive signer
RECIPIENT_EXPIREDA recipient’s signing window passes without completion

Template lifecycle

EventWhen it fires
TEMPLATE_CREATEDA new template is created
TEMPLATE_UPDATEDA template’s content or settings change
TEMPLATE_DELETEDA template is deleted
TEMPLATE_USEDA template is used to send a new document

Most integrations subscribe to DOCUMENT_COMPLETED at minimum (record the signed PDF in your system) plus one or two intermediate events for visibility.

Payload shape

Each webhook POST has this structure:

{
  "event": "DOCUMENT_COMPLETED",
  "createdAt": "2026-05-12T17:42:18.123Z",
  "webhookEndpoint": "https://your-server.com/webhooks/pacta",
  "payload": {
    "id": 42,
    "title": "Q4 vendor agreement",
    "status": "COMPLETED",
    "createdAt": "2026-05-10T14:30:00.000Z",
    "completedAt": "2026-05-12T17:42:18.000Z",
    "recipients": [ ... ],
    "fields": [ ... ],
    "externalId": "your-internal-ref-123",
    ...
  }
}

The payload shape depends on the event type — for DOCUMENT_* events it’s the full document object, for TEMPLATE_* events it’s the template. Exact shapes are in the OpenAPI spec under the WebhookPayload schema.

externalId — the field that makes integration tractable

When you create a document via the API, pass an externalId — your own reference for that document (e.g., your CRM deal ID, your contract record UUID). Pacta stores it and echoes it back on every webhook for that document.

This means: your webhook handler doesn’t need to maintain a Pacta-ID ↔ your-ID lookup table. You read payload.externalId, look up your own record directly. Single most useful API design decision we made.

Signature verification

Each webhook POST includes a Pacta-Signature header. Verify it before trusting the payload — anyone can POST to your URL, but only Pacta can sign with the secret you configured.

Verification (Node.js example)

import crypto from 'node:crypto';

function verifyWebhook(req: Request, signingSecret: string): boolean {
  const signature = req.headers.get('Pacta-Signature');
  if (!signature) return false;

  const body = req.body; // raw bytes, NOT JSON.parsed
  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(body)
    .digest('hex');

  // Constant-time compare to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex'),
  );
}

Verification (Python example)

import hashlib
import hmac

def verify_webhook(body: bytes, signature: str, signing_secret: str) -> bool:
    expected = hmac.new(
        signing_secret.encode(),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

Critical: use the raw request body, not the JSON-parsed version. JSON serialization is not byte-stable across implementations (different key order, whitespace, etc.) — verifying against a re-serialized payload will fail intermittently.

If verification fails, return a 401 and ignore the payload. Don’t process anything you can’t verify.

Retry behavior

If your endpoint doesn’t return 2xx within 30 seconds, Pacta retries:

  • After 1 minute
  • After 5 minutes
  • After 30 minutes
  • After 2 hours
  • After 6 hours
  • After 24 hours

After 6 failed attempts (~33 hours total), Pacta marks the delivery as failed and stops retrying. The event stays in your webhook’s Delivery log (Team Settings → Webhooks → click the endpoint → Recent deliveries) where you can manually re-trigger.

Idempotency: if you retry, Pacta might deliver the same event more than once. Always treat webhook handlers as idempotent — use the event ID or document externalId + event type as your dedupe key.

What to do in your handler

The pattern that survives production:

// Pseudo-code — adapt to your framework
app.post('/webhooks/pacta', async (req, res) => {
  // 1. Verify signature (rejects unauthorized POSTs)
  if (!verifyWebhook(req, PACTA_WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // 2. Acknowledge IMMEDIATELY — return 200 in <1 second
  res.status(200).send('ok');

  // 3. Process asynchronously (queue + worker)
  //    Don't do heavy work inline; you'll time out
  await queue.publish('pacta.webhook', req.body);
});

// Worker
worker.on('pacta.webhook', async (msg) => {
  const { event, payload } = msg;

  // Dedupe: don't double-process the same event
  const dedupeKey = `pacta:${payload.id}:${event}`;
  if (await redis.exists(dedupeKey)) return;
  await redis.set(dedupeKey, '1', 'EX', 86400);

  // Use externalId to look up YOUR record
  const record = await db.contracts.findOne({
    pactaExternalId: payload.externalId,
  });

  // Update your state
  if (event === 'DOCUMENT_COMPLETED') {
    await db.contracts.update(record.id, {
      status: 'signed',
      signedPdfUrl: payload.documentDataUrl,
      completedAt: payload.completedAt,
    });
  }
  // ... other events
});

Key points:

  • Verify first. Anyone can POST to your URL.
  • Ack fast. Return 200 in <1s; do work async. Pacta times out at 30s and will retry, which means duplicate events.
  • Be idempotent. Use the document ID + event type as a dedupe key, with a 24h+ TTL.
  • Use externalId. Saves a lookup table.
  • Log failures with the raw payload. When things go wrong, having the original JSON makes debugging tractable.

Testing webhooks locally

Two approaches:

Approach 1 — ngrok / Cloudflare Tunnel

Expose your local dev server to the public internet:

ngrok http 3000
# Forwards to https://random-string.ngrok-free.app

Use that URL when creating the webhook in Pacta. Pacta POSTs to ngrok, ngrok forwards to your localhost.

Approach 2 — Pacta’s webhook tester

Inside your webhook’s detail page in Pacta, there’s a Send test event button. It fires a synthetic event with realistic payload to your endpoint without needing a real document workflow. Use it to verify signature + ack timing.

Cleaning up

Webhooks that fail repeatedly may eventually get auto-disabled by Pacta after consistent 5xx responses for 7 days (so we don’t keep hammering a broken endpoint). You’ll see a notification in your team dashboard. Re-enable from the webhook detail page after fixing.

To delete a webhook permanently, Team Settings → Webhooks → right-click the endpoint → Delete.

Where to go next

  • API overview — the other half of programmatic integration: making API calls
  • Audit trails + verification — the signed PDF you receive on DOCUMENT_COMPLETED is self-verifying even after you store it
Have a question this doc didn't answer? Email us and we'll fix it.