eContract Developers

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 now
import 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 now

Response (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…"
}
ActionEndpointScope
List (with each endpoint's last delivery)GET /api/v1/webhookswebhooks:read
CreatePOST /api/v1/webhooks with { url, events, description? }webhooks:write
Get (with the 20 latest deliveries)GET /api/v1/webhooks/{id}webhooks:read
UpdatePATCH /api/v1/webhooks/{id} with { url?, events?, description?, is_active? }webhooks:write
DeleteDELETE /api/v1/webhooks/{id}webhooks:write
Send a test eventPOST /api/v1/webhooks/{id}/testwebhooks:write
Delivery logGET /api/v1/webhooks/{id}/deliveries?page=1&limit=20 (max 100)webhooks:read
Retry a failed deliveryPOST /api/v1/webhooks/deliveries/{deliveryId}/retrywebhooks:write

A workspace can have 20 active endpoints.

Allowed URLs

  • https:// (plain http:// 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 2xx from the URL itself.

Events

EventSent when
contract.createdA contract is created (POST /contracts) or created by POST /contracts/send (data.via is create or send)
contract.processing.completedA sent contract finished processing and is in_signing
contract.processing.failedProcessing failed (data.processingError); the contract keeps processingStatus: "failed"
contract.submittedA contract was submitted and the signing workflow started
workflow.startedSame moment as contract.submitted
signer.invitedAn invitation email went out to a signer
signer.viewedAn external signer opened the contract for the first time
contract.signedOne signer signed (sent once per signature, together with signer.completed)
signer.completedA signer signed
contract.completedEveryone signed; the sealed PDF and audit trail are ready
workflow.completedSame moment as contract.completed
signer.declinedA signer declined (data.reason); the contract is voided
contract.voidedThe contract was voided: by its sender (POST /contracts/{id}/void) or because a signer declined (data.reason)
workflow.voidedSame moment as contract.voided when the sender voids the contract
contract.expiredThe signing deadline passed
workflow.expiredSame moment as contract.expired
workflow.reminder_sentAn automatic reminder email went to a signer
webhook.testYou sent a test event

Delivery mode link. When a contract is sent with delivery: "link" the workflow starts (contract.submitted, workflow.started) but nobody is invited by email, so there is no signer.invited and 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.completed tells 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"
  }
}
FieldDescription
idEvent id (evt_…). The same for every endpoint, every retry and every manual retry of this event: use it to de-duplicate
eventEvent name
timestampWhen the event was dispatched (ISO 8601)
livemodeAlways true for now (test keys are not separated yet)
data.contract{ id, code, title, status }, with the status right after the event
data.signerSigner 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

HeaderValue
Content-Typeapplication/json
User-AgenteContract-Webhooks/1.0
X-Econtract-EventEvent name, for example contract.completed
X-Econtract-Event-IdEvent id, same as id in the body (stable across retries)
X-Econtract-DeliveryId of this delivery attempt (new on every attempt)
X-Econtract-TimestampUnix time (seconds) of this attempt; part of the signature
X-Econtract-Signaturet=<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 "", 200

Retries

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:

AttemptWhen
1Right away
21 minute after attempt 1 failed
35 minutes later
430 minutes later
52 hours later
624 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 (or X-Econtract-Event-Id): retries and manual retries reuse it. Events can arrive out of order; use data.contract.status or fetch the contract when order matters.
  • Answer 200 fast 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.

On this page