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
- Pick a URL on your server that can accept POST requests
- In Pacta → Team Settings → Webhooks → + New webhook
- Paste your URL
- Select the events you want to receive
- Copy the signing secret (
whsec_...) — you’ll use it to verify deliveries - Pacta now POSTs to your URL for each matching event
Available events
Fourteen event types, grouped by domain:
Document lifecycle
| Event | When it fires |
|---|---|
DOCUMENT_CREATED | A new document is created (via UI or API) |
DOCUMENT_SENT | The document is sent for signature |
DOCUMENT_OPENED | A recipient opens the signing email |
DOCUMENT_SIGNED | A recipient applies their signature (still waiting on others) |
DOCUMENT_RECIPIENT_COMPLETED | A specific recipient finishes their part |
DOCUMENT_COMPLETED | All recipients have signed; document is sealed |
DOCUMENT_REJECTED | A recipient explicitly declines |
DOCUMENT_CANCELLED | The sender cancels the envelope |
DOCUMENT_REMINDER_SENT | Pacta auto-sends a reminder to a non-responsive signer |
RECIPIENT_EXPIRED | A recipient’s signing window passes without completion |
Template lifecycle
| Event | When it fires |
|---|---|
TEMPLATE_CREATED | A new template is created |
TEMPLATE_UPDATED | A template’s content or settings change |
TEMPLATE_DELETED | A template is deleted |
TEMPLATE_USED | A 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_COMPLETEDis self-verifying even after you store it