Webhooks

Webhooks allow your application to receive real-time notifications when events occur in APISign. Instead of repeatedly polling our API, webhooks push event data to your application immediately when something happens.

Base URL:https://apisign.io/api

Every endpoint on this page is authenticated with your API key in the x-api-key header, and every one of them takes and returns JSON. REST routes do not check the key's permission, so a Read Only key can create, change, and delete webhooks too.


Webhook Endpoints

Delete is a POST

/webhook/delete takes the id in a JSON body over POST, not as a query parameter over DELETE. The same is true of /webhook/update, /webhook/test, and /webhook/rotate-secret.


Available Events

Event names use underscores. Any other spelling is rejected by /webhook/create and /webhook/update with a 400.

EventDescription
contract_createdA contract was created through /contract/create or the dashboard. Contracts created with the MCP contract_create tool do not emit it.
contract_sentContract was sent to signers
contract_resentA contract already in sent status was sent again
contract_viewedA signer opened their signing link. Fires every time the link is opened, not once per signer.
contract_signedA signer completed their signature
contract_completedAll parties have signed the contract. Fires right after the last signer's contract_signed.
contract_declinedA signer declined to sign
contract_expiredA sent contract passed its expires_at — see below
contract_cancelledContract was cancelled through the API, dashboard, or MCP. A contract that becomes cancelled because a signer declined emits only contract_declined.

That is the complete list. There are no template events.

Don't depend on contract_expired

contract_expired is not emitted when the deadline passes. A separate scheduled job looks for sent contracts whose expires_at has passed, sets their status to expired and emits the event, so it can arrive well after the deadline. When it fires, data.signer is null and data.metadata carries expires_at and expired_by: "system_cron". The signing link stops working at expires_at whether or not the job has run, so treat expires_at from /contract/get as the deadline.


Webhook Payload

Every delivery is a POST with this body:

{
  "id": "clx8k2q1b0002qz3f6j0e3c8f",
  "event": "contract_signed",
  "created_at": "2026-07-28T14:30:00.000Z",
  "data": {
    "organization_id": "clx8k1m4z0000qz3f7g2c1a9d",
    "contract": {
      "id": "clx8k2n7b0003qz3f8j4e3c1f",
      "name": "Service Agreement",
      "status": "sent",
      "sent_at": "2026-07-28T14:02:11.000Z",
      "test_mode": false
    },
    "signer": {
      "id": "clx8k2o9c0004qz3f1k5f4d2g",
      "name": "Jordan Lee",
      "email": "jordan@acme.com",
      "status": "signed",
      "signed_at": "2026-07-28T14:30:00.000Z"
    },
    "metadata": {}
  }
}
FieldNotes
idThe audit log entry that produced this event. Every webhook subscribed to the event gets the same id, and it is stable across retries — use it for idempotency.
eventThe event type, in the underscore form above. There is no type field.
created_atISO 8601 string, not a Unix timestamp. Set when the event was recorded; retries resend the same value.
data.contractnull for events with no contract context. status is the contract's status when the event was recorded, and retries resend that snapshot unchanged.
data.signernull for events with no signer context: contract_created, contract_sent, contract_resent, contract_completed, contract_expired, contract_cancelled.
data.metadataEvent-specific extras, such as fieldValues on contract_signed or declineReason on contract_declined. May be absent entirely.

A completed contract has status signed

When every signer has signed, the contract's status becomes signed. There is no completed status — contract_completed is the name of the event, not of the state.


Webhook Headers

Every delivery carries these headers, plus any custom headers you set on the webhook. Custom headers are applied last, so one with the same name replaces the header below.

HeaderDescription
Content-Typeapplication/json
X-Webhook-Signaturet=<unix-seconds>,v1=<hex-hmac> — see below
X-Webhook-TimestampThe same Unix timestamp, as a string
X-Webhook-IDID of the delivery — one event to one webhook. It stays the same across retries of that delivery, and matches the id in /webhook/get and /webhook/logs.

The event type is not in a header. Read it from event in the body. The timestamp and signature are recomputed on every attempt, so a retry carries a fresh t.


Signature Verification

Webhook Security

Always verify webhook signatures to ensure requests are genuine. Sign the raw request body — not a re-serialized copy of the parsed JSON, which will not match byte for byte.

X-Webhook-Signature has the form t=1753712345,v1=9f86d081.... To verify:

  1. Split the header on , and read t= and v1=.
  2. Reject the request if t is more than 5 minutes from now.
  3. Compute HMAC-SHA256(key = your webhook secret, message = "<t>.<raw body>") and hex-encode it.
  4. Compare that to v1 with a constant-time comparison.

The key is the whsec_... secret exactly as returned by /webhook/create, used as a UTF-8 string.

Node.js Example

const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  // Header format: t=<unix seconds>,v1=<hex hmac>
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => {
      const i = p.indexOf('=');
      return [p.slice(0, i), p.slice(i + 1)];
    })
  );

  const timestamp = parts.t;
  const received = parts.v1;
  if (!timestamp || !received) return false;

  // Reject replays older than 5 minutes
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('hex');

  // timingSafeEqual throws on length mismatch, so check length first
  if (received.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

// Usage in Express.js — note express.raw, so rawBody is the exact bytes we signed
app.post('/webhooks/apisign', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');

  if (!verifyWebhookSignature(rawBody, req.get('X-Webhook-Signature'), process.env.APISIGN_WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(rawBody);

  switch (event.event) {
    case 'contract_signed':
      // one signer finished
      break;
    case 'contract_completed':
      // everyone finished; contract.status is now "signed"
      break;
  }

  res.status(200).send('OK');
});

Python Example

import hmac
import hashlib
import time


def verify_webhook_signature(raw_body: str, signature_header: str, secret: str) -> bool:
    # Header format: t=<unix seconds>,v1=<hex hmac>
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = parts.get("t")
    received = parts.get("v1")
    if not timestamp or not received:
        return False

    # Reject replays older than 5 minutes
    if abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.{raw_body}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(received, expected)

Delivery and Retries

A delivery is attempted as soon as the event is recorded. A delivery counts as failed if the response status is not 2xx, the connection cannot be established, the TLS handshake fails, or no response arrives within 30 seconds.

Each delivery gets up to 5 attempts. The backoff after each failed attempt is:

After attemptNext attempt scheduled in
11 minute
25 minutes
330 minutes
42 hours
5No further attempts — the delivery is marked failed

A failure counter runs across deliveries, incrementing on every failed attempt and resetting to 0 on the first success. When it reaches 10, the webhook's status becomes failed and no new events are queued for it — so two fully exhausted deliveries are enough to disable an endpoint. Set status back to active with /webhook/update to re-enable it; that also resets the counter. Events that occurred while it was failed or inactive are not replayed.

Retries are not on a timer yet

No background scheduler processes retries. Due retries are picked up only when your organization produces another event that one of its active webhooks subscribes to; that pass works through up to 100 due deliveries across all of the organization's webhooks, including ones since set to failed or inactive. If your organization goes quiet, a failed delivery waits past its scheduled time, possibly indefinitely. Treat webhooks as best-effort and reconcile against /contract/get when correctness matters. Delivery history, including every failed attempt, is available from /webhook/get, /webhook/logs, and the dashboard.


Best Practices

Respond Quickly

Return a 2xx response immediately, then process the event asynchronously:

app.post('/webhooks/apisign', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verifyWebhookSignature(rawBody, req.get('X-Webhook-Signature'), secret)) {
    return res.status(401).send('Invalid signature');
  }

  webhookQueue.add('process-event', JSON.parse(rawBody));
  res.status(200).send('OK');
});

Handle Duplicates

A retried delivery repeats the same payload, so dedupe on the payload's id:

function handleWebhookEvent(event) {
  if (alreadyProcessed(event.id)) return;
  processEvent(event);
  markProcessed(event.id);
}

Use HTTPS

We do not reject http:// endpoint URLs, but you should not use one. The payload contains signer names and email addresses, and the signature only proves the request came from us — it does not encrypt anything.


Testing Webhooks

Send a test delivery

POST /webhook/test with {"id": "<webhook id>"} sends a synthetic contract_signed delivery to your URL and returns the status code, body, and duration it got back, along with the exact payload and headers it sent. The same thing is available in the dashboard under Account → Webhooks.

Test deliveries are signed with the webhook's secret like real ones, but the payload carries "test": true, the request carries an extra X-Webhook-Test: true header, and both the payload id and X-Webhook-ID are test_<milliseconds>. A test is sent even when the webhook is inactive or failed, is attempted once with no retries, is not recorded in your delivery history, and does not touch the failure counter.

{
  "id": "test_1753712345678",
  "event": "contract_signed",
  "created_at": "2026-07-28T14:30:00.000Z",
  "test": true,
  "data": {
    "organization_id": "clx8k1m4z0000qz3f7g2c1a9d",
    "contract": { "id": "test_contract_id", "name": "Test Contract", "status": "signed", "test_mode": true },
    "signer": { "id": "test_signer_id", "name": "Test Signer", "email": "test@example.com", "status": "signed" },
    "metadata": { "message": "This is a test webhook delivery" }
  }
}

Local Development

Use ngrok to expose your local server:

ngrok http 3000
# Use the HTTPS URL: https://abc123.ngrok.io/webhooks/apisign