Webhook Signing

Verifying outbound webhook deliveries with HMAC SHA-256.

AdvancedownermanagerdeveloperUpdated 2026-09-02

Webhook Signing

Current V2 status (2026-09-02): Outbound webhooks are live in V2: destinations are managed in Settings > Integrations > Zapier / Make Webhooks or through GET and POST /api/v1/webhooks plus GET and DELETE /api/v1/webhooks/:id with a live key, deliveries are signed exactly as described below, and failed deliveries retry after 10 seconds, 60 seconds, and 5 minutes. One V2 difference: the signed body carries a top-level id (the delivery id) that matches X-Webhook-ID.

You can register outbound webhook destinations with the external API or from Settings -> Integrations -> Webhooks.

Each destination gets a signing secret. CE Pro signs every delivery with HMAC SHA-256 so your server can verify authenticity before processing the payload.

Supported Events

  • lead.created
  • lead.status_changed
  • estimate.created
  • estimate.accepted
  • job.created
  • job.completed
  • invoice.created
  • invoice.paid

invoice.paid is the lifetime-first zero-balance event. Its transactional uniqueness boundary prevents duplicate delivery from concurrent final tenders, provider retries, or later refund-and-recollection cycles. Consumers should still retain their normal webhook-delivery idempotency handling.

Delivery Headers

Every outbound delivery includes:

  • Content-Type: application/json
  • X-Webhook-ID
  • X-Webhook-Signature
  • X-Webhook-Event
  • X-Webhook-Timestamp
  • X-Webhook-Attempt

Use X-Webhook-ID as the delivery identifier on your side for dedupe and replay tracking.

Example Payload

{
  "id": "6b4f2c1a-8d3e-4f5a-9b2c-1d0e9f8a7b6c",
  "event": "job.completed",
  "timestamp": "2026-03-14T16:45:00.000Z",
  "data": {
    "job_id": "2d74d878-9a62-45da-84de-74d91c4a2b4d",
    "job_number": "J-1042",
    "client_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "previous_status": "in_progress",
    "status": "complete"
  }
}

The top-level id is the delivery id (V2), the same value as X-Webhook-ID, and it is part of the signed body.

Verify The Signature

Compute an HMAC SHA-256 signature of the raw request body using the webhook secret you stored when the destination was created.

Node.js

import crypto from 'crypto'

export function verifyCeProWebhook(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')

  const expectedBuffer = Buffer.from(expected, 'utf8')
  const receivedBuffer = Buffer.from(signatureHeader || '', 'utf8')

  return (
    expectedBuffer.length === receivedBuffer.length &&
    crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
  )
}

Python

import hashlib
import hmac

def verify_cepro_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")

Retry Behavior

If your endpoint does not return a 2xx response, CE Pro queues retries using durable delivery records and retries after approximately 10s, 60s, and 300s.

Because retries happen, your endpoint should be idempotent. Store the X-Webhook-ID you have already processed and ignore duplicates.

Only live webhook destinations are processed in v1. Test API keys cannot register webhook endpoints, and the delivery worker ignores any non-live queue rows.

Inbound Provider Callbacks To CE Pro

CE Pro also receives inbound callbacks from providers such as Mailgun. Those provider-to-CE Pro requests do not use the outbound HMAC scheme above.

For inbound email routing, configure your provider to send:

  • Authorization: Bearer <EMAIL_INBOUND_WEBHOOK_SECRET>

Do not place the inbound secret in the query string. CE Pro no longer accepts query-string-only authentication for inbound email callbacks.

Example route target:

POST https://your-domain.com/api/webhooks/email-inbound
Authorization: Bearer <EMAIL_INBOUND_WEBHOOK_SECRET>

URL Restrictions

CE Pro accepts only external HTTPS webhook URLs. Localhost, private-network, and internal-only targets are rejected for safety.

Was this article helpful?

Still need help? Contact support