Skip to content

D365 Sales Quote Timeline Updates with Velosity Webhooks

Overview

Velosity is the source of truth for Sales Quotes and their lifecycle status. D365 does not contain a corresponding Quote record. Velosity sends Sales Quote webhooks to middleware, which creates timeline notes on the related Microsoft Dynamics 365 (D365) Account or Contact.

Velosity Sales Quote -> Webhook outbox -> Middleware -> D365 Web API -> Timeline activity

This avoids Power Automate and produces consistent, searchable, reportable, and auditable D365 entries.

The D365 timeline is the date-ordered activity feed on an Account or Contact where notes, emails, and calls appear. The Sales Quote remains in Velosity. Each D365 note contains the Velosity quote number, lifecycle activity, and a link back to the quote instead of copying the quote into D365.

Webhooks vs. Scheduled Polling

A scheduled job that queries Velosity for quotes updated in the last 24 hours should not be the primary integration mechanism.

LastUpdatedUTC records any update to a Sales Quote, not specifically a status transition. Polling for LastUpdatedUTC plus the current status can therefore:

  • Treat an ordinary edit to an already accepted quote as a new win.
  • Miss a status transition when the quote changes again before the daily job runs.
  • Miss records because of job delays or gaps at the 24-hour boundary.
  • Reprocess records unless middleware maintains its own checkpoint and deduplication state.
  • Delay D365 updates by as much as one day.

Velosity's existing outbox-backed StatusChanged webhook identifies the transition when it occurs, retries failed deliveries, and supplies a stable delivery ID. It is the preferred source for D365 updates. A scheduled reconciliation job can remain as a fallback, but it should compare durable event or audit records from a saved high-water mark rather than query the current quote status using a rolling 24-hour window.

In the Sales Quote lifecycle, the business meaning of "Won" is ACCEPTED: the customer approved the offered quote. "Lost" maps to REJECTED. RELEASED is a later operational step that creates or advances downstream order work. The D365 lifecycle-note triggers are therefore StatusChanged with Status equal to OFFERED, ACCEPTED, or REJECTED.

Delivery Reliability

The current webhook infrastructure addresses several delivery risks:

  • An outbox stores each delivery before it is sent.
  • Failed requests are retried instead of being discarded immediately.
  • Delivery attempts and responses are logged for investigation.
  • Endpoint diagnostics help identify connectivity, DNS, TLS, and HTTP response problems.
  • X-Velosity-Delivery-Id supports idempotent processing of retries.
  • HMAC signatures allow the receiving service to verify payload authenticity.

These controls improve delivery reliability but cannot make an unavailable middleware endpoint reachable. Extended service downtime, firewall or allowlist rules, DNS failures, TLS configuration, and failures inside the receiving service still require operational monitoring and remediation.

The recommended design therefore combines immediate webhooks with scheduled reconciliation. Webhooks provide timely status updates, while reconciliation detects events that remain unprocessed after prolonged outages. Reconciliation should use a persisted checkpoint and overlap window, then deduplicate by event or delivery identity. A rolling query for records updated in the previous 24 hours is insufficient on its own.

Velosity Webhooks

Current behavior

Entity Event When it fires Payload
SalesQuote StatusChanged After a successful status update Full SalesQuoteModel
SalesQuote Created Once, after successful Sales Quote persistence Full SalesQuoteModel

The distinct Created event prevents middleware from having to infer creation from an initial DRAFT status. That inference is ambiguous because an existing quote can return to DRAFT.

Velosity queues deliveries in its webhook outbox. The delivery worker retries failures. When a webhook secret is configured, Velosity adds an HMAC-SHA256 signature in the X-Velosity-Signature header.

Webhooks are delivered from production. Contact Velosity to enable delivery on a test instance.

Sales Quote Status Lifecycle

Middleware should use Status from the SalesQuoteModel payload.

Status D365 timeline subject Meaning
OFFERED Quote Offered The quote was offered to the customer.
ACCEPTED Quote Accepted The customer accepted the quote.
REJECTED Quote Rejected The customer rejected the quote.

Velosity has no separate sent, won, or lost events. Use the OFFERED, ACCEPTED, and REJECTED statuses. Middleware can label the D365 note in business terms while keeping the original Velosity status in the activity details.

Middleware Mapping

Webhook event Payload status D365 action
Created Usually DRAFT Optionally create "Quote Created."
StatusChanged OFFERED Create "Quote Offered."
StatusChanged ACCEPTED Create "Quote Accepted."
StatusChanged REJECTED Create "Quote Rejected."

Other statuses do not create D365 timeline notes.

The middleware needs a durable mapping from the Velosity customer to the corresponding D365 Account or Contact. There is no D365 Quote ID to resolve or bind. The mapping rules must define whether a company quote attaches to its Account, a person-specific quote attaches to its Contact, or both receive a note.

D365 Authentication

Use a Microsoft Entra ID app registration and OAuth 2.0 client credentials.

POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
client_id={clientId}
client_secret={clientSecret}
scope=https://{org}.crm.dynamics.com/.default
grant_type=client_credentials

Cache the access token until shortly before expiry and keep credentials in a secure secret store.

Creating the D365 Timeline Entry

The simplest implementation creates a Note linked to the relevant D365 record:

POST https://{org}.crm.dynamics.com/api/data/v9.2/annotations
Authorization: Bearer {token}
Content-Type: application/json
{
  "subject": "Quote Offered",
  "notetext": "Velosity Sales Quote Q-1234 was offered. Open quote: https://{velosity-host}/sales/quotes/{SALES_QUOTE_ID}",
  "objectid_account@odata.bind": "accounts({D365_ACCOUNT_ID})"
}

For a Contact timeline, use the D365 annotation binding for that Contact instead:

{
  "subject": "Quote Accepted",
  "notetext": "Velosity Sales Quote Q-1234 was accepted. Open quote: https://{velosity-host}/sales/quotes/{SALES_QUOTE_ID}",
  "objectid_contact@odata.bind": "contacts({D365_CONTACT_ID})"
}

A custom D365 activity is appropriate when a dedicated icon, structured status field, or richer reporting justifies the extra customization.

Middleware Pattern

async function handleSalesQuoteWebhook(webhookEvent, quote, deliveryId) {
  if (await wasProcessed(deliveryId)) return;

  const regarding = await resolveD365AccountOrContact(quote.Customer);
  const activity = buildTimelineActivity(webhookEvent, quote, regarding);
  const token = await getD365Token();

  await axios.post(
    `${D365_URL}/api/data/v9.2/annotations`,
    activity,
    { headers: { Authorization: `Bearer ${token}` } }
  );

  await markProcessed(deliveryId);
}

function buildTimelineActivity(webhookEvent, quote, regarding) {
  const quoteUrl = `${VELOSITY_URL}/sales/quotes/${quote.SalesQuoteID}`;

  if (webhookEvent === "Created") {
    return {
      subject: "Quote Created",
      notetext: `Velosity Sales Quote ${quote.Number} was created. Open quote: ${quoteUrl}`,
      [regarding.binding]: regarding.path
    };
  }

  const subjects = {
    OFFERED: "Quote Offered",
    ACCEPTED: "Quote Accepted",
    REJECTED: "Quote Rejected"
  };

  const subject = subjects[quote.Status];
  if (!subject) throw new Error(`Unsupported Sales Quote status: ${quote.Status}`);

  return {
    subject,
    notetext: `Velosity Sales Quote ${quote.Number} changed status to ${quote.Status}. Open quote: ${quoteUrl}`,
    [regarding.binding]: regarding.path
  };
}

Delivery Requirements

  • Verify X-Velosity-Signature against the exact request body.
  • Deduplicate deliveries using X-Velosity-Delivery-Id.
  • Return success only after accepting the event durably.
  • Retry transient D365 failures with bounded exponential backoff.
  • Send permanently failed events to a dead-letter queue.
  • Log the delivery ID, SalesQuoteID, quote number, status, D365 Account or Contact ID, result, and correlation ID.
  • Ignore unchanged or unsupported statuses.

Implementation Summary

The D365 integration should subscribe to the Sales Quote StatusChanged webhook and post Account or Contact timeline notes for OFFERED, ACCEPTED, and REJECTED. Each note references the Velosity quote number and links back to Velosity; no quote is copied to D365. Subscribe to the distinct Created webhook when D365 should also receive a quote-creation note.