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

  1. Navigate to SettingsWebhooks in the Deal Engine Dashboard
  2. Under Authentication, select your preferred method:
    • HMAC Signature: For cryptographic request signing
    • API Key: For static key authentication
  3. 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
  4. (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
  5. 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 TypeDisplay NameDescription
refund_request.status_updatedQuote updatedTriggered when a refund quote status changes
refund_candidate.status_updatedRefund candidate updatedTriggered 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_updated is the quote/refund-request update event, and refund_candidate.status_updated is the refund-candidate update event. Older dashboard versions may still show the swapped labels (e.g., "Request updated" for refund_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"
  }
}
FieldTypeDescription
versionstringAPI version of the webhook payload
typestringThe event type identifier
eventIdstringUnique identifier for this event
creatednumberUnix timestamp (seconds) when the event was created
dataobjectTicket 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

ParameterValue
Retry attempts3 retries after the initial delivery (up to 4 POSTs total)
Backoff scheduleNo backoff; a fixed 30-second interval between attempts
Total retry window90 seconds from the first attempt
Response timeout15 seconds per attempt
Success criteriaAny HTTP response in the 2xx range (200299) 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 size256 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). eventId is 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:

  1. Query webhookEvents filtered by the outage time range to identify which events were not delivered.
  2. 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:

TenantDelivery IPs
US52.33.112.220, 54.68.52.188
EU18.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:

  1. Extract the API key from the header (default: X-API-KEY or your custom header name)
  2. 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
ComponentDescription
tUnix timestamp (seconds) when the request was signed
sHMAC 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

IssuePossible CauseSolution
Signature verification failsUsing parsed body instead of rawEnsure you're using the raw request body
Signature verification failsWrong secretVerify your webhook secret matches
Signature verification failsBody modified by middlewareConfigure middleware to preserve raw body
Timestamp too oldClock driftSync your server's clock with NTP
Events not receivedFirewall blockingAllow incoming requests from Deal Engine's delivery IPs; see Network requirements
Duplicate events receivedNormal behaviorImplement idempotency using eventId

Testing Your Integration

  1. Use the webhook events log: View sent webhooks in the Deal Engine dashboard
  2. Check response codes: Ensure your endpoint returns 2xx for successful processing
  3. Verify signature locally: Test your signature verification with known values

Support

If you encounter issues with webhook integration:

  1. Check the webhook event logs in the Deal Engine dashboard
  2. Verify your endpoint is accessible from the internet
  3. Review your signature verification implementation
  4. Contact Deal Engine support with your eventId for debugging

Did this page help you?
All rights reserved © 2025 deal-engine.com.