Verifying deliveries
Anyone can POST to your endpoint URL. Every Dealops delivery is signed with your endpoint's signing secret, and you should verify that signature before acting on a payload — it's the only thing that distinguishes a real event from a forged one.
Request headers
| Header | Description |
|---|---|
Content-Type | Always application/json. |
User-Agent | Dealops-Webhooks/1.0. |
X-Dealops-Event-Id | UUID of the source event. Use this as your idempotency key. |
X-Dealops-Event-Type | The event type, e.g. quote.signed. |
X-Dealops-Delivery-Id | UUID of this delivery. Changes between retries — do not dedupe on it. |
X-Dealops-Timestamp | Unix seconds at the moment the signature was computed. |
X-Dealops-Signature | v1=<hex> — see below. |
X-Dealops-Attempt | 1-indexed attempt number; the first delivery is 1. |
X-Dealops-Test | Present with value true only on test deliveries. Absent on real events. |
Header names are case-insensitive, as in any HTTP request — most frameworks lowercase them for you.
Payload envelope
Every delivery has the same outer shape, with the event-specific detail under data:
{
"id": "3f2b9c14-...",
"type": "quote.signed",
"organizationId": "8a1e...",
"occurredAt": "2026-08-19T12:34:56.000Z",
"data": {
"...": "event-specific fields"
}
}
id matches X-Dealops-Event-Id and type matches X-Dealops-Event-Type, so a handler that only reads the body doesn't need the headers to route.
Treat data as additive: fields may be added to it within v1, so ignore what you don't recognize rather than validating strictly.
The signature
The signed material is the timestamp and the raw request body, joined by a period:
signature = HMAC_SHA256(signing_secret, "{timestamp}.{raw_body}")
sent as X-Dealops-Signature: v1=<hex digest>. The v1= prefix is a scheme version, so a future scheme can be added without breaking existing verifiers.
Verify against the exact body bytes you received, before any JSON parsing or re-serializing. Parsing and re-encoding changes key order and whitespace, which changes the hash and fails verification for reasons that are painful to debug. Most frameworks need explicit configuration to keep the raw body available — do that first.
Node
import crypto from 'crypto';
function verifyDelivery(headers, rawBody, signingSecret) {
const signature = headers['x-dealops-signature']; // "v1=…"
const timestamp = headers['x-dealops-timestamp'];
if (!signature || !timestamp) return false;
// Reject stale timestamps so a captured request can't be replayed later.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
'v1=' +
crypto
.createHmac('sha256', signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
// timingSafeEqual throws on length mismatch, so check length first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python
import hmac, hashlib, time
def verify_delivery(headers, raw_body: bytes, signing_secret: str) -> bool:
signature = headers.get("X-Dealops-Signature")
timestamp = headers.get("X-Dealops-Timestamp")
if not signature or not timestamp:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
digest = hmac.new(
signing_secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, f"v1={digest}")
Two details both examples share, and neither is optional:
- Compare in constant time (
timingSafeEqual/compare_digest). A plain==on a secret-derived value leaks information through timing. - Check the timestamp. The signature alone never expires, so without a freshness window a captured delivery stays replayable forever. Dealops surfaces the timestamp; enforcing a tolerance (five minutes above) is your side of the contract.
Test deliveries
Sending a test event from Dealops settings delivers a synthetic webhook.ping:
{
"id": "…",
"type": "webhook.ping",
"organizationId": "…",
"occurredAt": "…",
"data": { "message": "Test delivery from Dealops…" }
}
Test deliveries are signed exactly like real ones, so they're a good end-to-end check of your verification code. They also carry X-Dealops-Test: true, so a handler can acknowledge and no-op instead of creating real records. Note that webhook.ping is not a subscribable event type — it only ever arrives as a test.