Webhooks

Receive real-time HTTP notifications when a Certified Digital Record (CDR) is ready to use.

On this page

Overview

After the SDK captures a CDR, there is a short processing delay before the evidence image is available. Webhooks notify your server the moment processing completes, so you can act on it immediately without polling the API.

ExpressConsent sends a POST request to your configured URL with a JSON payload containing the CDR details. Currently, one event type is supported:

  • cdr.completed — the CDR is fully processed and ready for download

Setup

  1. Sign in to the dashboard and go to Organization → Settings.
  2. Enter your webhook endpoint URL (e.g. https://yourserver.com/webhooks/expressconsent).
  3. (Optional but recommended) Click Generate secret to create a signing secret. Copy the secret and store it securely in your server’s environment variables. See Signature verification for details.
  4. Save. ExpressConsent will start sending events to that URL immediately.

To disable webhooks, clear the URL field and save. Pending retries for previously queued events will still attempt delivery.

Payload

Every webhook is a POST request with Content-Type: application/json. The body contains a single event object:

cdr.completed example

json
{
  "event": "cdr.completed",
  "orgId": "cid_your_org",
  "cdrId": "abc_123",
  "domainId": "example.com",
  "domain": "example.com",
  "createdAt": 1738600000000,
  "ip": "203.0.113.1",
  "userAgent": "Mozilla/5.0 ...",
  "downloadUrl": "https://storage.googleapis.com/... (short-lived)",
  "customMetadata": { "leadId": "123" },
  "sessionId": "session_abc",
  "disclosures": [
    {
      "key": "tcpa",
      "language": "By submitting this form, you consent to receive calls via autodialer...",
      "agreed": true
    }
  ],
  "webhookId": "wh_a1b2c3d4",
  "timestamp": 1738600005000
}

Fields

eventstring

Always "cdr.completed"

orgIdstring

Your organization ID (CID). Useful for routing when you have multiple organizations.

cdrIdstring

The CDR identifier. Store this with your lead record.

domainIdstring

Domain that produced the CDR.

domainstring

Domain name.

createdAtnumber

When the CDR was created (epoch milliseconds).

ipstring | null

Signer’s IP address (best-effort).

userAgentstring | null

Signer’s User-Agent (best-effort).

downloadUrlstring

Short-lived signed URL for the evidence image. Only present when the CDR is collected.

customMetadataobject

Custom metadata passed to captureCDR() via the custom option.

sessionIdstring

Session identifier (for grouping).

subGroupIdsstring[]

Sub-group identifiers (co-registration flows).

packageIdstring

Package identifier (session-level grouping).

disclosuresarray

Disclosure text and agreement status reported by the integration. Each entry has key (identifier), language (the text), and agreed (boolean). Empty or absent means no disclosures were reported. See Disclosure Tracking.

webhookIdstring

Unique identifier for this delivery. Use for deduplication.

timestampnumber

When the webhook was sent (epoch milliseconds).

Signature verification

When you configure a signing secret in the dashboard, every webhook delivery includes HMAC-SHA256 signature headers so you can verify the request genuinely came from ExpressConsent and was not tampered with. Signature verification is optional but strongly recommended for production use.

Signature headers

When a signing secret is configured, each webhook request includes three headers:

X-EC-Signaturestring

The HMAC-SHA256 signature of the request, in the format sha256=<hex digest>.

X-EC-Timestampstring

Unix timestamp (seconds) when the webhook was signed. Use this for replay protection.

X-EC-Webhook-Idstring

Unique delivery identifier. Always present (even without a signing secret). Use for deduplication.

How to verify

  1. Extract the X-EC-Signature, X-EC-Timestamp, and the raw JSON body from the request.
  2. Reject stale timestamps. If the timestamp is more than 5 minutes old, reject the request to prevent replay attacks.
  3. Reconstruct the signed message by concatenating the timestamp, a literal period (.), and the raw JSON body: {timestamp}.{body}
  4. Compute the HMAC-SHA256 of that message using your signing secret. Prefix the hex digest with sha256=.
  5. Compare your computed signature with the X-EC-Signature header using a constant-time comparison function to prevent timing attacks.

Node.js example

javascript
import crypto from "node:crypto";

const WEBHOOK_SECRET = process.env.EC_WEBHOOK_SECRET; // your signing secret from the dashboard
const MAX_AGE_SECONDS = 300; // 5 minutes — reject older timestamps

function verifySignature(req) {
  const signature = req.headers["x-ec-signature"];
  const timestamp = req.headers["x-ec-timestamp"];
  const webhookId = req.headers["x-ec-webhook-id"];
  const rawBody = JSON.stringify(req.body); // must match the raw JSON body

  // 1. Check required headers are present
  if (!signature || !timestamp || !webhookId) {
    return { valid: false, reason: "Missing signature headers" };
  }

  // 2. Reject stale timestamps (replay protection)
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (Number.isNaN(age) || age > MAX_AGE_SECONDS) {
    return { valid: false, reason: "Timestamp too old" };
  }

  // 3. Compute expected signature
  const message = timestamp + "." + rawBody;
  const expected = "sha256=" + crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(message)
    .digest("hex");

  // 4. Constant-time comparison
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return { valid: false, reason: "Signature mismatch" };
  }

  return { valid: true, webhookId };
}

app.post("/webhooks/expressconsent", express.json(), (req, res) => {
  const result = verifySignature(req);
  if (!result.valid) {
    console.warn("Webhook signature invalid:", result.reason);
    return res.status(401).send("Unauthorized");
  }

  const event = req.body;
  console.log("Verified webhook:", event.cdrId, "id:", result.webhookId);

  res.status(200).send("ok");
});

Example handler

A minimal Express endpoint that receives webhook events (without signature verification):

javascript
app.post("/webhooks/expressconsent", express.json(), (req, res) => {
  const event = req.body;

  if (event.event === "cdr.completed") {
    // Match to your lead record using customMetadata or cdrId
    console.log("CDR ready:", event.cdrId, event.customMetadata);

    // event.downloadUrl is a short-lived signed URL (when collected).
    // Store the cdrId, not the URL — fetch a fresh URL via the API when needed.
  }

  // Always return 200 quickly to avoid retries
  res.status(200).send("ok");
});

For a production handler with signature verification, see the signature verification section above.

Retries

If your endpoint returns a non-2xx status code or the request times out (20-second limit), ExpressConsent retries delivery with exponential backoff:

  • Max attempts: 5
  • Backoff: 30 seconds to 10 minutes between retries
  • Total retry window: up to 1 hour

After all retries are exhausted, the delivery is marked as failed. Webhook deliveries are idempotent on our side — once a webhook is successfully delivered (2xx response), it will not be sent again.

Best practices

  • Verify signatures in production. Configure a signing secret and verify the X-EC-Signature header to ensure requests are authentic. See Signature verification.
  • Return 200 quickly. Process the webhook asynchronously (e.g. enqueue a job) and return 200 immediately. Long-running handlers risk timeouts and unnecessary retries.
  • Handle duplicates. Use webhookId as an idempotency key. Your handler should be safe to call twice with the same event.
  • Store cdrId, not downloadUrl. Download URLs expire. Fetch a fresh URL via the API when you need to download the evidence image.
  • Match via customMetadata. Pass a lead ID or phone number in captureCDR({ custom: { leadId: '...' } }) so the webhook payload contains the fields you need to match the CDR back to your lead record.

Related docs