Webhooks
Deal Engine Webhook Integration Guide
This guide explains how to receive and verify webhook events from Deal Engine in your application.
Overview
Deal Engine uses webhooks to notify your application in real-time when events occur. Instead of polling our API for updates, you can configure webhook endpoints to receive automatic notifications.
Key Features:
- ✅ Real-time event notifications
- ✅ Two authentication methods: HMAC Signature or API Key
- ✅ Configurable via UI or API
- ✅ Support for multiple event types
- ✅ Retry mechanism for failed deliveries; see Delivery Guarantees & Retry Contract for the exact numbers
Webhooks are the supported live-update mechanism. GraphQL subscriptions are not offered; build on webhooks (or polling).
Authentication Options
Deal Engine supports two authentication methods for securing webhook deliveries. You can configure your preferred method through the Deal Engine Dashboard or via the API.
Option 1: HMAC Signature (Recommended)
With HMAC authentication, each webhook request is cryptographically signed using your secret key. This provides:
- Per-request verification: Each request has a unique signature
- Timestamp protection: Prevents replay attacks
- Payload integrity: Ensures the data hasn't been tampered with
Header format:
X-DE-WEBHOOK-SIGNATURE: t=1623436092,s=7e526f3c14539d4d...
Option 2: API Key
With API Key authentication, a static key is sent with every request in the header you specify. This is simpler to implement but provides less security than HMAC.
Header format (example):
X-API-KEY: your-api-key-here
Configuring via the Dashboard
- Navigate to Settings → Webhooks in the Deal Engine Dashboard
- Under Authentication, select your preferred method:
- HMAC Signature: For cryptographic request signing
- API Key: For static key authentication
- Configure your secret/key:
- Enter your own: Provide a custom secret or API key
- Generate for me: Let Deal Engine create a secure random key
- (Optional) Customize the header name: Use your own header name or keep the defaults:
- HMAC default:
X-DE-WEBHOOK-SIGNATURE - API Key default:
X-API-KEY
- HMAC default:
- Click Save to apply your configuration
Tip: You can view your secret at any time in the Dashboard, and use the "Roll Secret" button to generate a new one if needed.
Rolling the secret is a hard cutover; there is no grace/overlap period. The previous secret stops being accepted immediately. Any deliveries already in flight or queued for retry that were signed with the old secret will fail verification once you roll it. Update your verification code with the new secret before rolling it, and roll during a quiet period to minimize in-flight deliveries signed with the old secret.
Available Event Types
| Event Type | Display Name | Description |
|---|---|---|
refund_request.status_updated | Quote updated | Triggered when a refund quote status changes |
refund_candidate.status_updated | Refund candidate updated | Triggered when a refund candidate status changes |
The dashboard display names were previously inverted; this was a labelling bug, now fixed above. The table above is correct:refund_request.status_updatedis the quote/refund-request update event, andrefund_candidate.status_updatedis the refund-candidate update event. Older dashboard versions may still show the swapped labels (e.g., "Request updated" forrefund_candidate.status_updated). Subscribe and branch on the event name, never on the display name.
Webhook Payload Structure
Every webhook event follows this structure:
{
"version": "1.0",
"type": "refund_candidate.status_updated",
"eventId": "evt_abc123xyz",
"created": 1623436092,
"data": {
"ticket_num": "0651234567890",
"status": "REFUNDED",
"pnr": "ABC123"
}
}| Field | Type | Description |
|---|---|---|
version | string | API version of the webhook payload |
type | string | The event type identifier |
eventId | string | Unique identifier for this event |
created | number | Unix timestamp (seconds) when the event was created |
data | object | Ticket number, new status, and PNR (default shape) |
Status values per event type, wire casing, and custom payload options are documented in the Webhook API Reference.
Delivery Guarantees & Retry Contract
Building a consumer that survives an outage without losing events depends on knowing exactly how Deal Engine retries deliveries and what guarantees apply. This section is the operational contract; the code in Best Practices implements it.
Retry policy
| Parameter | Value |
|---|---|
| Retry attempts | 3 retries after the initial delivery (up to 4 POSTs total) |
| Backoff schedule | No backoff; a fixed 30-second interval between attempts |
| Total retry window | 90 seconds from the first attempt |
| Response timeout | 15 seconds per attempt |
| Success criteria | Any HTTP response in the 2xx range (200–299) is treated as a successful delivery. Any other status code, a connection error, or no response within the 15-second timeout counts as a failed attempt and is retried per the schedule. |
| Maximum payload size | 256 KB |
Once the retry window is exhausted, Deal Engine stops attempting delivery for that event; there is no automatic re-delivery after that point. See Recovering from extended downtime below.
Ordering and duplicates
- Ordering: Until confirmed, don't assume strict ordering; design handlers to be safe against out-of-order arrival, for example by reading the ticket's current status directly rather than trusting the sequence of received events.
- Duplicates: The same event can be delivered more than once (see Handle Duplicates).
eventIdis stable across retry attempts of the same logical event; it is safe to use as a dedup key. The dedup pattern in this guide relies on this.
Recovering from extended downtime
There is currently no backfill or replay endpoint: webhookEvents (see Webhook API Reference) is a read-only delivery log, not a resend mechanism. If your endpoint is down longer than the retry window above, events emitted during that time are not automatically redelivered once you come back up.
To recover from an outage:
- Query
webhookEventsfiltered by the outage time range to identify which events were not delivered. - Reconcile by polling the refund/candidate status directly for the affected tickets instead of relying on the missed events; see Tracking a refund to completion.
There is no mechanism to request that Deal Engine replay or resend webhook events for a past time range or set of eventIds; polling the affected tickets is the only recovery path.
Network requirements
IP allowlisting: If your firewall requires an explicit allowlist for inbound traffic, allow Deal Engine's outbound delivery IPs for your tenant. These are the same for sandbox and production:
| Tenant | Delivery IPs |
|---|---|
| US | 52.33.112.220, 54.68.52.188 |
| EU | 18.159.183.165, 63.181.70.117 |
TLS: HTTPS is required; plain http:// endpoint URLs are not accepted (see URL Requirements). Deal Engine uses TLS 1.3 for webhook delivery.
Testing your endpoint before going live
There is no test/ping event mechanism to validate a newly configured endpoint before subscribing it to live events. Use a staging environment subscription (see Testing Your Integration) to validate connectivity and signature verification before pointing production events at it.
Security: Verifying Webhook Requests
Always verify webhook requests before processing them to ensure they originate from Deal Engine.
Verifying API Key Authentication
If you configured API Key authentication, verification is straightforward:
- Extract the API key from the header (default:
X-API-KEYor your custom header name) - Compare it with your stored secret
function verifyApiKey(req) {
const headerName = 'x-api-key'; // Use your configured header name
const receivedKey = req.headers[headerName];
if (!receivedKey) return false;
// Use constant-time comparison to prevent timing attacks.
// Guard the length first: timingSafeEqual throws on length mismatch.
const received = Buffer.from(receivedKey);
const expected = Buffer.from(process.env.WEBHOOK_API_KEY);
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}Verifying HMAC Signature Authentication
If you configured HMAC Signature authentication, follow these steps:
Why Verify HMAC Signatures?
- Authenticity: Confirm requests originate from Deal Engine
- Integrity: Ensure the payload hasn't been tampered with
- Replay Protection: The timestamp prevents replay attacks
The Signature Header
Every webhook request includes a signature header (default: X-DE-WEBHOOK-SIGNATURE or your custom header name):
X-DE-WEBHOOK-SIGNATURE: t=1623436092,s=7e526f3c14539d4d2856a1a2e8b1112c944cd466670041fe758fcc930d8cdf23
| Component | Description |
|---|---|
t | Unix timestamp (seconds) when the request was signed |
s | HMAC SHA-256 signature in hexadecimal format |
Step 1: Extract Timestamp and Signature
Parse the signature header to extract the t and s values:
const header = req.headers['x-de-webhook-signature']; // Use your configured header name
const [tPart, sPart] = header.split(',');
const timestamp = tPart.split('=')[1];
const signature = sPart.split('=')[1];Step 2: Construct the Signed Payload
Concatenate the timestamp and raw request body with a period (.) separator:
const signedPayload = `${timestamp}.${rawRequestBody}`;
Important: Use the raw, unparsed request body exactly as received. Any formatting changes (whitespace, key ordering) will cause verification to fail.
Step 3: Calculate Expected Signature
Compute an HMAC SHA-256 hash using your webhook secret:
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');Step 4: Compare Signatures
Use a constant-time comparison to prevent timing attacks:
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);Step 5: Verify Timestamp (Recommended)
Check that the timestamp is recent (within 5 minutes) to prevent replay attacks:
const currentTime = Math.floor(Date.now() / 1000);
const tolerance = 300; // 5 minutes
if (Math.abs(currentTime - parseInt(timestamp)) > tolerance) {
throw new Error('Timestamp too old');
}Code Examples
Choose the example that matches your configured authentication method.
API Key Authentication Examples
Node.js / Express (API Key)
const crypto = require('crypto');
const express = require('express');
const app = express();
const API_KEY = process.env.WEBHOOK_API_KEY;
const HEADER_NAME = 'x-api-key'; // Use your configured header name
app.post('/webhooks/deal-engine',
express.json(),
(req, res) => {
try {
// Verify API key
if (!verifyApiKey(req.headers)) {
return res.status(401).json({ error: 'Invalid API key' });
}
const event = req.body;
switch (event.type) {
case 'refund_request.status_updated':
handleQuoteUpdate(event.data);
break;
case 'refund_candidate.status_updated':
handleRequestUpdate(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(400).json({ error: 'Webhook processing failed' });
}
}
);
function verifyApiKey(headers) {
const receivedKey = headers[HEADER_NAME];
if (!receivedKey) return false;
// Constant-time comparison; guard length first (timingSafeEqual throws on mismatch)
const received = Buffer.from(receivedKey);
const expected = Buffer.from(API_KEY);
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}Python / Flask (API Key)
import hmac
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
API_KEY = os.environ.get('WEBHOOK_API_KEY')
HEADER_NAME = 'X-API-KEY' # Use your configured header name
@app.route('/webhooks/deal-engine', methods=['POST'])
def handle_webhook():
try:
if not verify_api_key(request.headers):
return jsonify({'error': 'Invalid API key'}), 401
event = request.get_json()
if event['type'] == 'refund_request.status_updated':
handle_quote_update(event['data'])
elif event['type'] == 'refund_candidate.status_updated':
handle_request_update(event['data'])
return jsonify({'received': True}), 200
except Exception as e:
return jsonify({'error': str(e)}), 400
def verify_api_key(headers):
received_key = headers.get(HEADER_NAME)
if not received_key:
return False
return hmac.compare_digest(received_key, API_KEY)HMAC Signature Authentication Examples
Node.js / Express (HMAC)
const crypto = require('crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const HEADER_NAME = 'x-de-webhook-signature'; // Use your configured header name
// Important: Use raw body parser for HMAC verification
app.post('/webhooks/deal-engine',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const rawBody = req.body.toString('utf8');
if (!verifyHmacSignature(req.headers, rawBody)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
switch (event.type) {
case 'refund_request.status_updated':
handleQuoteUpdate(event.data);
break;
case 'refund_candidate.status_updated':
handleRequestUpdate(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(400).json({ error: 'Webhook processing failed' });
}
}
);
function verifyHmacSignature(headers, rawBody) {
const header = headers[HEADER_NAME];
if (!header) return false;
const [tPart, sPart] = header.split(',');
const timestamp = tPart.split('=')[1];
const receivedSignature = sPart.split('=')[1];
// Check timestamp freshness (5 minute tolerance)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
return false;
}
// Calculate expected signature
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// Constant-time comparison; guard length first (timingSafeEqual throws on mismatch)
const received = Buffer.from(receivedSignature);
const expected = Buffer.from(expectedSignature);
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}Python / Flask (HMAC)
import hmac
import hashlib
import time
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
HEADER_NAME = 'X-DE-WEBHOOK-SIGNATURE' # Use your configured header name
@app.route('/webhooks/deal-engine', methods=['POST'])
def handle_webhook():
try:
raw_body = request.get_data(as_text=True)
if not verify_hmac_signature(request.headers, raw_body):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json()
if event['type'] == 'refund_request.status_updated':
handle_quote_update(event['data'])
elif event['type'] == 'refund_candidate.status_updated':
handle_request_update(event['data'])
return jsonify({'received': True}), 200
except Exception as e:
return jsonify({'error': str(e)}), 400
def verify_hmac_signature(headers, raw_body):
signature_header = headers.get(HEADER_NAME)
if not signature_header:
return False
parts = signature_header.split(',')
timestamp = parts[0].split('=')[1]
received_signature = parts[1].split('=')[1]
# Check timestamp freshness
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300:
return False
# Calculate expected signature
signed_payload = f"{timestamp}.{raw_body}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(received_signature, expected_signature)C# / ASP.NET Core (HMAC)
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
// Minimal payload type; extend to match the event payload you subscribe to
public record WebhookEvent(string Type, JsonElement Data);
[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
private readonly string _webhookSecret;
private readonly string _headerName = "X-DE-WEBHOOK-SIGNATURE"; // Use your configured header name
public WebhookController(IConfiguration config)
{
_webhookSecret = config["WebhookSecret"];
}
[HttpPost("deal-engine")]
public async Task<IActionResult> HandleWebhook()
{
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync();
if (!VerifyHmacSignature(Request.Headers, rawBody))
{
return Unauthorized(new { error = "Invalid signature" });
}
var webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(rawBody);
switch (webhookEvent.Type)
{
case "refund_request.status_updated":
await HandleQuoteUpdate(webhookEvent.Data); // your handler
break;
case "refund_candidate.status_updated":
await HandleRequestUpdate(webhookEvent.Data); // your handler
break;
}
return Ok(new { received = true });
}
private bool VerifyHmacSignature(IHeaderDictionary headers, string rawBody)
{
if (!headers.TryGetValue(_headerName, out var signatureHeader))
return false;
var parts = signatureHeader.ToString().Split(',');
var timestamp = parts[0].Split('=')[1];
var receivedSignature = parts[1].Split('=')[1];
// Check timestamp freshness
var currentTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(currentTime - long.Parse(timestamp)) > 300)
return false;
// Calculate expected signature
var signedPayload = $"{timestamp}.{rawBody}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
var expectedSignature = BitConverter.ToString(hash).Replace("-", "").ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(receivedSignature),
Encoding.UTF8.GetBytes(expectedSignature)
);
}
}Best Practices
1. Return 2xx Quickly
Return a 200 or 202 response as soon as you receive the webhook. Process heavy operations asynchronously:
app.post('/webhooks/deal-engine', (req, res) => {
// Verify first, using verifyHmacSignature or verifyApiKey defined above
if (!verifyHmacSignature(req.headers, req.rawBody)) {
return res.status(401).send();
}
// Acknowledge immediately
res.status(202).json({ received: true });
// Process asynchronously
processWebhookAsync(req.body).catch(console.error);
});2. Handle Duplicates
Webhook events may be delivered more than once. Use the eventId field to implement idempotency:
async function handleWebhook(event) {
// Check if already processed
if (await isEventProcessed(event.eventId)) {
return; // Skip duplicate
}
// Process the event
await processEvent(event);
// Mark as processed
await markEventProcessed(event.eventId);
}3. Implement Retry Logic
If your endpoint fails, Deal Engine retries delivery up to 3 times after the initial attempt, with a fixed 30-second interval between attempts (90-second total retry window); see Delivery Guarantees & Retry Contract for the full contract. Configure your endpoint to handle retries (and possible duplicate or out-of-order arrival) gracefully, and don't rely on retries alone to recover from downtime longer than the total retry window; see Recovering from extended downtime.
4. Secure Your Endpoint
- Use HTTPS for your webhook endpoint
- Always verify signatures before processing
- Keep your webhook secret secure (use environment variables)
- Implement rate limiting if needed
5. Log Webhook Events
Maintain logs for debugging and auditing:
function logWebhookEvent(event, status) {
console.log({
timestamp: new Date().toISOString(),
eventId: event.eventId,
eventType: event.type,
status: status
});
}Troubleshooting
Common Issues
| Issue | Possible Cause | Solution |
|---|---|---|
| Signature verification fails | Using parsed body instead of raw | Ensure you're using the raw request body |
| Signature verification fails | Wrong secret | Verify your webhook secret matches |
| Signature verification fails | Body modified by middleware | Configure middleware to preserve raw body |
| Timestamp too old | Clock drift | Sync your server's clock with NTP |
| Events not received | Firewall blocking | Allow incoming requests from Deal Engine's delivery IPs; see Network requirements |
| Duplicate events received | Normal behavior | Implement idempotency using eventId |
Testing Your Integration
- Use the webhook events log: View sent webhooks in the Deal Engine dashboard
- Check response codes: Ensure your endpoint returns 2xx for successful processing
- Verify signature locally: Test your signature verification with known values
Support
If you encounter issues with webhook integration:
- Check the webhook event logs in the Deal Engine dashboard
- Verify your endpoint is accessible from the internet
- Review your signature verification implementation
- Contact Deal Engine support with your
eventIdfor debugging
Updated 13 days ago