Verafirma logo Verafirma

Outbound webhooks

Webhooks are how the API tells you about state changes that happen between your calls. Register a URL, the wrapper POSTs to it whenever a subscribed event fires, your handler verifies the HMAC signature and reacts.

This page covers the customer-side outbound webhooks (the API calling your URL). It does not cover inbound webhooks the wrapper handles internally (Documenso → wrapper, Relaystation → wrapper); those don’t surface to API consumers.

Registering a webhook

curl -X POST https://api.verafirma.com/v1/webhooks \
  -H "Authorization: Bearer vf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhooks/verafirma",
    "events": ["envelope.completed", "envelope.signed"],
    "description": "production handler"
  }'

Response (201):

{
  "id": "wh_<uuid>",
  "url": "https://your-app.example.com/webhooks/verafirma",
  "events": ["envelope.completed", "envelope.signed"],
  "active": true,
  "secret": "whsec_<32-hex>",
  "consecutiveFailures": 0,
  "createdAt": "..."
}

The secret field is returned ONCE. Subsequent reads of the registration omit it. Store it on registration; if it’s lost, delete the registration and re-register.

V1 events

The seven events the wrapper emits:

EventWhen
envelope.sentEnvelope distributed; signing-link emails sent.
envelope.viewedA recipient opened the signing link.
envelope.signedA SIGNER recipient completed their signature. Fires once per signer.
envelope.completedAll SIGNER recipients have signed; envelope is terminal at COMPLETED.
envelope.rejectedA SIGNER rejected; envelope is terminal at REJECTED.
envelope.expiredSigning window closed without all signers; envelope is terminal at EXPIRED.
envelope.cancelledEnvelope cancelled by the customer or an operator; envelope is terminal at CANCELLED.

Subscribe with events: ["*"] to receive all events. Subscribe with a narrower list to receive only those.

Delivery shape

Each delivery is a POST with a JSON body and signing headers:

POST /webhooks/verafirma HTTP/1.1
Content-Type: application/json
X-Verafirma-Signature: t=<unix-ts>,v1=<hex-hmac>
X-Verafirma-Event: envelope.completed
X-Verafirma-Delivery-Id: dlv_<uuid>

{
  "event": "envelope.completed",
  "deliveredAt": "2026-05-07T...",
  "envelope": {
    "envelopeId": "...",
    "documensoEnvelopeId": "...",
    "status": "COMPLETED",
    "title": "...",
    "recipients": [...],
    "createdAt": "..."
  }
}

The body is the same shape GET /v1/envelopes/{id} would return for that envelope, plus the event metadata at the top level.

Verifying signatures

The X-Verafirma-Signature header carries an HMAC-SHA256 over ${timestamp}.${raw_body}, signed with the registration secret. Verify before trusting the body.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyWebhook(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((kv) => kv.split('=')),
  );
  const timestamp = parts.t;
  const v1 = parts.v1;
  if (!timestamp || !v1) return false;

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

  // constant-time compare
  const a = Buffer.from(v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length) return false;
  if (!timingSafeEqual(a, b)) return false;

  // optional but recommended: reject deliveries older than 5 min.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > 300) return false;

  return true;
}

The t= timestamp is unix seconds. Including it in the signed payload prevents an attacker who captures one delivery from replaying it later.

The 5-minute freshness window above is a recommended client-side check; the API doesn’t enforce it.

Retry behavior

On non-2xx responses (or no response within the request timeout), the delivery is queued for retry. The default retry schedule:

1m → 5m → 15m → 1h → 6h → 24h → done (terminal failure logged)

The schedule is configurable on the operator side; the cap on retries is too. If your handler returns 2xx within the schedule, the delivery is marked successful; otherwise it transitions to terminal failure and stops retrying.

After enough consecutive failures the registration’s consecutiveFailures counter reaches a threshold and the operator can choose to disable the registration. Successful deliveries reset the counter to zero.

Idempotency on your side

The X-Verafirma-Delivery-Id header is unique per delivery. Two deliveries with the same id are rare (network blips at the SQS layer can occasionally produce them) but possible.

The recommended pattern: persist the delivery id when you process it; on a future delivery with the same id, return 2xx without re-running the side effect. This is a one-row, one-index pattern — cheap, prevents duplicate downstream actions.

Managing registrations

# List your registrations:
GET /v1/webhooks

# Read a single registration (no secret in the response):
GET /v1/webhooks/{id}

# Update events list, URL, description, or active flag:
PUT /v1/webhooks/{id}

# Delete (deliveries history retained for audit):
DELETE /v1/webhooks/{id}

# Page through delivery history:
GET /v1/webhooks/{id}/deliveries?limit=&offset=&status=

The status filter on the deliveries list is one of PENDING, SUCCESS, FAILED. Use the deliveries log when debugging why your handler isn’t seeing an event you expected.

What NOT to do

  • Don’t trust the body without verifying the signature. A handler that processes the body without signature verification is the standard webhook footgun; an attacker who knows your URL can POST anything they want.
  • Don’t store the secret in client-side code. It’s a server-side credential. If your URL is reachable from the client side, your handler still runs server-side; keep the secret there.
  • Don’t return 2xx until the side effect is durable. A 2xx response tells the wrapper “delivery acknowledged”; if your downstream side effect failed but you returned 2xx anyway, the wrapper won’t retry.
  • Don’t poll instead of subscribing. GET /v1/envelopes/{id}/status is for ad-hoc checks, not for pub-sub. Webhooks scale; polling doesn’t.