Webhooks

Webhooks allow your application to receive real-time notifications when events occur in API Sign. 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.


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 and /webhook/test.


Available Events

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

EventDescription
contract_createdA new contract was created
contract_sentContract was sent to signers
contract_resentContract was resent to signers
contract_viewedA signer opened the contract
contract_signedA signer completed their signature
contract_completedAll parties have signed the contract
contract_declinedA signer declined to sign
contract_expiredContract passed its expiration date
contract_cancelledContract was cancelled

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

contract_expired is not emitted yet

You can subscribe to contract_expired and the subscription is stored, but nothing currently marks a contract as expired in production, so the event never fires. Don't build on it yet. If you need expiry behaviour today, compare expires_at from /contract/get against the current time in your own code.


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. Stable across retries of the same event — 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.
data.contractnull for events with no contract context. status is the contract's status at send time.
data.signernull for events with no signer context, such as contract_created.
data.metadataEvent-specific extras. 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:

HeaderDescription
Content-TypeAlways application/json
X-Webhook-Signaturet=<unix-seconds>,v1=<hex-hmac> — see below
X-Webhook-TimestampThe same Unix timestamp, as a string
X-Webhook-IDIdentifier for this delivery attempt, not for the webhook

The event type is not in a header. Read it from event in the body.


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 the request does not complete 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 it stops receiving events entirely — 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.

Retries are not on a timer yet

Scheduled retries are processed when your organization produces its next webhook event, not by a background scheduler. If your organization goes quiet, a failed delivery can wait past its scheduled time. Treat webhooks as best-effort and reconcile against /contract/get when correctness matters. Delivery history, including every failed attempt, is available from /webhook/get and in 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 differ from real ones in two ways: the payload carries "test": true, and the request carries an extra X-Webhook-Test: true header. They are not recorded in your delivery history.

{
  "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