Webhooks
Get contract and signing events pushed to your server, verify them and handle retries
Webhooks tell your server when something happens to a contract: it finished processing, a signer opened it, signed or declined, everyone signed. Use them instead of polling.
Set up an endpoint
In the dashboard
Workspace owners and admins manage endpoints under Dashboard → Webhooks (/dashboard/webhooks): create an endpoint (URL, events, description), copy the signing secret (shown once), enable or disable it, send a test event, read the delivery log and retry failed deliveries.
With the API
The webhook endpoints accept a signed-in admin, an API key or an OAuth token with webhooks:read (reading) or webhooks:write (everything else). For a key or token the user behind it (the key creator, or the person who connected the app) must currently be an owner or admin of the workspace.
curl -X POST https://api.econtract.online/api/v1/webhooks \
-H "Authorization: Bearer cl_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/webhooks/econtract",
"events": ["contract.processing.completed", "contract.processing.failed", "signer.viewed", "contract.completed", "signer.declined"],
"description": "Production"
}'const res = await fetch("https://api.econtract.online/api/v1/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ECONTRACT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://your-app.example.com/webhooks/econtract",
events: ["contract.processing.completed", "contract.processing.failed", "signer.viewed", "contract.completed", "signer.declined"],
description: "Production",
}),
});
const { id, secret } = await res.json(); // store the secret: it is shown only nowimport os
import requests
res = requests.post(
"https://api.econtract.online/api/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['ECONTRACT_API_KEY']}"},
json={
"url": "https://your-app.example.com/webhooks/econtract",
"events": ["contract.processing.completed", "contract.processing.failed", "signer.viewed", "contract.completed", "signer.declined"],
"description": "Production",
},
)
webhook = res.json()
secret = webhook["secret"] # store it: it is shown only nowResponse (201), fields in snake_case:
{
"id": "5b0e8f0a-3f0e-4c1e-9b8e-2f4a6d7c9e10",
"workspace_id": "a1b2c3d4-…",
"url": "https://your-app.example.com/webhooks/econtract",
"events": ["contract.processing.completed", "contract.processing.failed", "signer.viewed", "contract.completed", "signer.declined"],
"description": "Production",
"is_active": true,
"created_by": "…",
"created_at": "2026-10-10T09:00:00.000Z",
"updated_at": "2026-10-10T09:00:00.000Z",
"secret": "9f2c…64 hex characters…"
}| Action | Endpoint | Scope |
|---|---|---|
| List (with each endpoint's last delivery) | GET /api/v1/webhooks | webhooks:read |
| Create | POST /api/v1/webhooks with { url, events, description? } | webhooks:write |
| Get (with the 20 latest deliveries) | GET /api/v1/webhooks/{id} | webhooks:read |
| Update | PATCH /api/v1/webhooks/{id} with { url?, events?, description?, is_active? } | webhooks:write |
| Delete | DELETE /api/v1/webhooks/{id} | webhooks:write |
| Send a test event | POST /api/v1/webhooks/{id}/test | webhooks:write |
| Delivery log | GET /api/v1/webhooks/{id}/deliveries?page=1&limit=20 (max 100) | webhooks:read |
| Retry a failed delivery | POST /api/v1/webhooks/deliveries/{deliveryId}/retry | webhooks:write |
A workspace can have 20 active endpoints.
Allowed URLs
https://(plainhttp://is refused in production), at most 2048 characters, no user name or password in the URL.- The host must resolve only to public internet addresses. Private, loopback, link-local, carrier-grade NAT, multicast and other special ranges are refused, including the same addresses written as IPv4-mapped IPv6. The check runs when you save the URL and again before every delivery, and the connection is pinned to the checked address.
- Redirects are not followed: answer
2xxfrom the URL itself.
Events
| Event | Sent when |
|---|---|
contract.created | A contract is created (POST /contracts) or created by POST /contracts/send (data.via is create or send) |
contract.processing.completed | A sent contract finished processing and is in_signing |
contract.processing.failed | Processing failed (data.processingError); the contract keeps processingStatus: "failed" |
contract.submitted | A contract was submitted and the signing workflow started |
workflow.started | Same moment as contract.submitted |
signer.invited | An invitation email went out to a signer |
signer.viewed | An external signer opened the contract for the first time |
contract.signed | One signer signed (sent once per signature, together with signer.completed) |
signer.completed | A signer signed |
contract.completed | Everyone signed; the sealed PDF and audit trail are ready |
workflow.completed | Same moment as contract.completed |
signer.declined | A signer declined (data.reason); the contract is voided |
contract.voided | The contract was voided: by its sender (POST /contracts/{id}/void) or because a signer declined (data.reason) |
workflow.voided | Same moment as contract.voided when the sender voids the contract |
contract.expired | The signing deadline passed |
workflow.expired | Same moment as contract.expired |
workflow.reminder_sent | An automatic reminder email went to a signer |
webhook.test | You sent a test event |
Delivery mode
link. When a contract is sent withdelivery: "link"the workflow starts (contract.submitted,workflow.started) but nobody is invited by email, so there is nosigner.invitedand no automatic reminder for those signers. This also holds for sequential signing: the next signer is not emailed after the previous one signs, so hand out each link when it is that signer's turn.contract.processing.completedtells you when to fetch the links.
Payload
Every delivery is a POST with a JSON body:
{
"id": "evt_2f6d8a3e-0c1b-4f7e-9a55-6b1d2e3f4a5b",
"event": "signer.viewed",
"timestamp": "2026-10-10T09:41:07.512Z",
"livemode": true,
"data": {
"contractId": "8c1e…",
"signerId": "3a7f…",
"signerEmail": "[email protected]",
"contract": { "id": "8c1e…", "code": "CTR-…", "title": "Service Agreement", "status": "in_signing" },
"signer": { "id": "3a7f…", "name": "Alice Johnson", "email": "[email protected]", "status": "viewed" },
"occurredAt": "2026-10-10T09:41:07.498Z"
}
}| Field | Description |
|---|---|
id | Event id (evt_…). The same for every endpoint, every retry and every manual retry of this event: use it to de-duplicate |
event | Event name |
timestamp | When the event was dispatched (ISO 8601) |
livemode | Always true for now (test keys are not separated yet) |
data.contract | { id, code, title, status }, with the status right after the event |
data.signer | Signer events: { id, name, email, status }; signer status is one of pending, notified, viewed, signed, rejected |
data.* | Event specific: contractId, signerId, signerEmail, reason, processingStatus, processingError, via, createdBy, occurredAt, … |
The body is limited to 64 KB.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | eContract-Webhooks/1.0 |
X-Econtract-Event | Event name, for example contract.completed |
X-Econtract-Event-Id | Event id, same as id in the body (stable across retries) |
X-Econtract-Delivery | Id of this delivery attempt (new on every attempt) |
X-Econtract-Timestamp | Unix time (seconds) of this attempt; part of the signature |
X-Econtract-Signature | t=<timestamp>,v1=<hex HMAC-SHA256> |
Verify the signature
The signature is HMAC-SHA256(secret, "<t>.<raw body>") in hex, where t is the timestamp from the header and the secret is the string you got when you created the endpoint. Compute it over the raw bytes you received (parse the JSON afterwards), compare in constant time, and reject old timestamps to stop replays.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.ECONTRACT_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = Object.fromEntries(
String(header || "").split(",").map((p) => p.trim().split("=", 2)),
);
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(parts.v1, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
const seen = new Set(); // use your database in production
app.post("/webhooks/econtract", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString("utf8"), req.get("X-Econtract-Signature"))) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
if (seen.has(event.id)) return res.sendStatus(200); // duplicate: already handled
seen.add(event.id);
// Answer fast, work in the background
res.sendStatus(200);
queueMicrotask(() => handle(event));
});
function handle(event) {
if (event.event === "contract.completed") {
// download GET /api/v1/contracts/{id}/document?version=signed and the audit trail
}
}
app.listen(3000);import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
SECRET = os.environ["ECONTRACT_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
app = Flask(__name__)
seen = set() # use your database in production
def verify(raw_body: bytes, header: str) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in (header or "").split(","))
t = int(parts["t"])
received = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, expected)
@app.post("/webhooks/econtract")
def econtract_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify(raw, request.headers.get("X-Econtract-Signature", "")):
return "invalid signature", 401
event = json.loads(raw)
if event["id"] in seen:
return "", 200 # duplicate: already handled
seen.add(event["id"])
# hand the event to a background job here
return "", 200Retries
A delivery succeeds when your endpoint answers 2xx within 30 seconds. Anything else (other status, redirect, timeout, connection or TLS error) is retried with the same body and event id:
| Attempt | When |
|---|---|
| 1 | Right away |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 24 hours later |
After the sixth failed attempt the delivery is marked failed. Delivery states are pending, retrying, success and failed.
Delivery log and manual retry
GET /api/v1/webhooks/{id}/deliveries (or the dashboard) lists every delivery with its event, event id, status, attempts, last response code, last error, the first 500 characters of your endpoint's last answer and the next retry time. Delivery history is kept for 30 days.
POST /api/v1/webhooks/deliveries/{deliveryId}/retry re-queues a failed delivery immediately, with the same payload and event id and a fresh set of attempts.
Good practice
- Verify every request and reject timestamps older than a few minutes.
- De-duplicate by
id(orX-Econtract-Event-Id): retries and manual retries reuse it. Events can arrive out of order; usedata.contract.statusor fetch the contract when order matters. - Answer
200fast and do the work in a background job. - Use HTTPS with a valid certificate on a public host.
- Watch the delivery log after deploying a new endpoint, and use Send test event (
webhook.test) to check your verification code.
Core Concepts
Core concepts of the eContract API, from workspaces and roles to the contract lifecycle, async document processing, signers, signing order and templates.
Idempotency
Retry eContract API writes safely with the Idempotency-Key header, covering where it works, how replays behave and how long results are kept.