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
Without webhooks, you would need to poll GET /v1/cdrs/:cdrId until the CDR appears. Webhooks eliminate that delay and reduce API calls.
Setup
- Sign in to the dashboard and go to Organization → Settings.
- Enter your webhook endpoint URL (e.g.
https://yourserver.com/webhooks/expressconsent). - (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.
- Save. ExpressConsent will start sending events to that URL immediately.
Your webhook endpoint must be publicly accessible and use HTTPS. HTTP URLs will not receive deliveries in production.
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
{
"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
eventstringAlways "cdr.completed"
orgIdstringYour organization ID (CID). Useful for routing when you have multiple organizations.
cdrIdstringThe CDR identifier. Store this with your lead record.
domainIdstringDomain that produced the CDR.
domainstringDomain name.
createdAtnumberWhen the CDR was created (epoch milliseconds).
ipstring | nullSigner’s IP address (best-effort).
userAgentstring | nullSigner’s User-Agent (best-effort).
downloadUrlstringShort-lived signed URL for the evidence image. Only present when the CDR is collected.
customMetadataobjectCustom metadata passed to captureCDR() via the custom option.
sessionIdstringSession identifier (for grouping).
subGroupIdsstring[]Sub-group identifiers (co-registration flows).
packageIdstringPackage identifier (session-level grouping).
disclosuresarrayDisclosure 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.
webhookIdstringUnique identifier for this delivery. Use for deduplication.
timestampnumberWhen the webhook was sent (epoch milliseconds).
The downloadUrl expires after a few minutes. Store the cdrId, not the URL. Use the API to fetch a fresh download URL when needed.
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-SignaturestringThe HMAC-SHA256 signature of the request, in the format sha256=<hex digest>.
X-EC-TimestampstringUnix timestamp (seconds) when the webhook was signed. Use this for replay protection.
X-EC-Webhook-IdstringUnique delivery identifier. Always present (even without a signing secret). Use for deduplication.
How to verify
- Extract the
X-EC-Signature,X-EC-Timestamp, and the raw JSON body from the request. - Reject stale timestamps. If the timestamp is more than 5 minutes old, reject the request to prevent replay attacks.
- Reconstruct the signed message by concatenating the timestamp, a literal period (
.), and the raw JSON body:{timestamp}.{body} - Compute the HMAC-SHA256 of that message using your signing secret. Prefix the hex digest with
sha256=. - Compare your computed signature with the
X-EC-Signatureheader using a constant-time comparison function to prevent timing attacks.
Node.js example
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");
});You can generate, rotate, or remove your signing secret at any time from Organization → Settings in the dashboard. When you rotate the secret, update your server’s environment variable before saving — in-flight deliveries signed with the old secret will fail verification.
If no signing secret is configured, the X-EC-Signature header is omitted. The X-EC-Timestamp and X-EC-Webhook-Id headers are always included regardless of whether a secret is set.
Example handler
A minimal Express endpoint that receives webhook events (without signature verification):
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.
Use the webhookId field to deduplicate deliveries on your end. In rare cases, a retry may arrive after a delayed success.
Best practices
- Verify signatures in production. Configure a signing secret and verify the
X-EC-Signatureheader to ensure requests are authentic. See Signature verification. - Return 200 quickly. Process the webhook asynchronously (e.g. enqueue a job) and return
200immediately. Long-running handlers risk timeouts and unnecessary retries. - Handle duplicates. Use
webhookIdas an idempotency key. Your handler should be safe to call twice with the same event. - Store
cdrId, notdownloadUrl. 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 incaptureCDR({ custom: { leadId: '...' } })so the webhook payload contains the fields you need to match the CDR back to your lead record.