Skip to content

Dynamics 365 & Azure Logic Apps

This is the integration reference for the CRM stack: the concrete endpoints, orgs, auth flows, and environment keys. For the why behind the design — the conceptual write-vs-read model and the country-header routing — read The CRM integration model first. This page is the lookup table you keep open while wiring or debugging a specific call.

There are three Microsoft Dynamics 365 orgs, one per country — Egypt, Oman, and Montenegro — each a separate Dynamics tenant with its own base URL and credential group (crms-middleware/config/services.php:34-70). The country HTTP header on each inbound call picks one; CountryName (crms-middleware/app/Enums/CountryName.php:5-33) maps the wire slug to a numeric id (1 = egypt, 2 = oman, 3 = montenegro).

The backend never calls Dynamics directly. Backend CLAUDE rule 3 forbids any direct external-CRM traffic — all of it routes through the crms-middleware HTTP boundary (env-prefixed CRMS_MIDDLEWARE_*). The middleware is the only component that holds Dynamics/Logic-App credentials and speaks the Dynamics dialect. Everything below lives in crms-middleware/.

Inside the middleware, the split is by direction, and each direction has its own base client and own token audience:

  • Writes (create a lead, reserve a sale, submit an EOI, register a broker) go to Azure Logic App HTTP endpoints via LinkDevAuthenticatedClient — Azure AD v1.0, resource=.
  • Reads (bulk inventory syncs, broker analytics, lookups) hit each Dynamics org’s OData Web API directly via CrmClient — Azure AD v2.0, scope=.

Every CRM write posts to an Azure Logic App HTTP trigger — never to Dynamics OData — through LinkDevAuthenticatedClient (app/Clients/LinkDevAuthenticatedClient.php:7). The Logic App is the integration gateway that Robusta/Link Dev built; it receives the mapped payload and fans the write out to the correct Dynamics org using the numeric country id carried in the request body.

The token. authenticate() (app/Clients/LinkDevAuthenticatedClient.php:9-41) reads config('services.crms.link_dev_auth') and form-POSTs client_id, client_secret, grant_type, and — the distinguishing param — resource (:16, :23). Sending resource= is the Azure AD v1.0 client-credentials flow. The bearer is cached under a single shared key, linkdev-crm-token (:43-46), so every write op across every country reuses one token.

The endpoint. Each write subclass resolves an absolute Logic-App URL from a *_url config key and calls postWithAuthentication / patchWithAuthentication with the mapped payload. For example LeadClient extends LinkDevAuthenticatedClient (app/Clients/Crms/LeadClient.php:8) and submitLead() POSTs config('services.crms.lead.submit_new_lead_url') (app/Clients/Crms/LeadClient.php:10-15).

The write clients and the config key each targets — every value is a distinct Logic-App URL supplied by env:

Write client (app/Clients/Crms/...) Action Config key Backing env var
LeadClient submit lead services.crms.lead.submit_new_lead_url LEAD_CRM_SUBMIT_NEW_LEAD_URL
LeadClient shopper submit lead services.crms.lead.shopper_submit_new_lead_url LEAD_SHOPPER_SUBMIT_NEW_LEAD_URL
LeadClient submit lead meeting services.crms.lead.submit_new_meeting_url LEAD_CRM_SUBMIT_NEW_MEETING_URL
UnitClient create draft sale services.crms.unit.create_draft_sale SALE_CRM_CREATE_DRAFT_SALE_URL
UnitClient reserve sale services.crms.unit.reserve_sale SALE_CRM_RESERVE_SALE_URL
UnitClient attach sale services.crms.unit.attach_sale SALE_CRM_ATTACH_SALE_URL
UnitClient update sale info services.crms.unit.update_info_sale SALE_CRM_UPDATE_INFO_URL
UnitClient reservation form services.crms.unit.get_reservation_form SALE_CRM_GET_RESERVATION_FORM
UnitClient payment terms services.crms.unit.payment_terms UNIT_PAYMENT_TERMS_CRM_URL
UnitClient sales offer services.crms.unit.sales_offer UNIT_SALES_OFFER
UnitClient submit add-on services.crms.unit.submit_addon UNIT_SUBMIT_ADDON
BrokerClient broker self-signup services.crms.broker.self_signup CRM_BROKER_SELF_SIGNUP
CustomerRequestClient submit unit-service services.crms.customer_request.submit_unit_service UNIT_SERVICE_SUBMIT
CustomerRequestClient resell assistance services.crms.customer_request.resell_assistance UNIT_RESELL_ASSISTANCE
EgpExchangeRateClient EGP FX rate services.crms.shopper.egp_exchange_rate_url CRM_EXCHANGE_RATE_URL
ShopperClient shopper details services.crms.shopper.details_url SHOPPER_DETAILS_URL
LaunchesClient/SubmitEOI submit EOI services.crms.eoi.api_submit_eoi_url CRM_API_EOI_SUBMIT_URL
SalesManCrmClient/* writes submit lead / sale / meeting / pipeline services.crms.salesman.api_submit_*_url SALESMAN_CRM_API_SUBMIT_*_URL
OmanCrmClient/OmanPreRegistration/RegisterBroker Oman broker pre-reg services.crms.oman.pre_registration.broker_register_path OMAN_CRM_PRE_REG_BROKER_REGISTER_PATH

Bulk inventory syncs, broker analytics, and reference-data lookups skip the Logic Apps and hit each Dynamics org’s Web API (OData) directly, through CrmClient (app/Clients/Crms/CrmClient.php:9) and its per-country subclasses (EgyptCrmClient/*, OmanCrmClient/*, MontenegroCrmClient/*).

The token. CrmClient::__construct() resolves the request-scoped CrmCredentials DTO (app/Clients/Crms/CrmClient.php:13-16), which AppServiceProvider::scopeCrmCredentials() builds per-country from config("services.crms.$country") (app/Providers/AppServiceProvider.php:313-330). authenticate() (app/Clients/Crms/CrmClient.php:23-49) form-POSTs client_id, client_secret, grant_type, and — the distinguishing param — scope (:31). Sending scope= is the Azure AD v2.0 client-credentials flow. The bearer is cached per country, keyed on the slug (getAuthTokenCacheName() returns $this->credentials->name, :18-21), so each org gets its own cached token.

The endpoint. Each reader builds {baseUrl}/api/data/v9.X/<entity> from its country base URL and calls getWithAuthentication with OData $select / $filter / $expand / $orderby. For example EgyptCrmClient/GetUnits::get() targets {baseUrl}/api/data/v9.1/new_units (app/Clients/Crms/EgyptCrmClient/GetUnits.php:24) and returns a [newlyCreated, newlyUpdated] pair (:57-76).

Two things vary across the reader matrix:

  • OData version is per-resource, not per-country. A reader hardcodes v9.1 or v9.2 in its URL, and the same operation uses the same version across all three orgs. v9.1 is used by the unit-inventory readers (GetUnits, GetUnitsWithParams, GetResaleUnits, GetOccupations, GetUnitAddons); v9.2 is everything else (lookups, leads reads, installments, all broker analytics).
  • Entity logical names differ per org. Egypt/Oman use the new_* dialect (new_units, new_countries); Montenegro uses the ldv_* dialect (ldv_units, ldv_countries). The full per-country entity-set matrix is in docs-site/_inventory/middleware.md (section 2.C).

Both directions authenticate against the same Azure AD app registration on the same tenant — the only real differences are mechanical, the token endpoint version and the credential param:

Aspect Writes (LinkDevAuthenticatedClient) Reads (CrmClient, per country)
Azure AD flow v1.0 client-credentials v2.0 client-credentials
Credential param resource= scope=
Config group services.crms.link_dev_auth services.crms.egypt / oman / montenegro
Env prefix LINK_DEV_* EGYPT_CRM_* / OMAN_CRM_* / MONTENEGRO_CRM_*
Token cache key linkdev-crm-token (one, shared) the country slug (one per org)
Grant type client_credentials client_credentials
Destination Logic-App HTTP triggers each org’s Dynamics Web API host

Each org is a distinct Dynamics tenant selected by the country header. Slug and numeric id come from CountryName (app/Enums/CountryName.php:5-33); the per-country credential groups live at config/services.php:35-70.

Country Header slug Numeric id Read-cred config group Base-URL env Notable divergence
Egypt egypt 1 services.crms.egypt EGYPT_CRM_BASE_URL new_* dialect; only org with Cities, LeadsCount, UnitAddons readers
Oman oman 2 services.crms.oman OMAN_CRM_BASE_URL new_* dialect; owns broker pre-registration (config path, plus a mock toggle)
Montenegro montenegro 3 services.crms.montenegro MONTENEGRO_CRM_BASE_URL ldv_* dialect; only org with a Connections reader

On the wire between backend and middleware, country is the slug. Inside a Dynamics write body, country is the numeric id. Same concept, two encodings — a slug-vs-id mismatch is not a routing bug.

Every key below is described by name only — no secret values are printed. Middleware keys are in crms-middleware/.env.example (mapped in config/services.php); backend keys are in backend/.env.example.

Azure AD write app (services.crms.link_dev_auth, config/services.php:165-171): LINK_DEV_AUTH_URL (the v1.0 token endpoint), LINK_DEV_CLIENT_ID, LINK_DEV_CLIENT_SECRET, LINK_DEV_GRANT_TYPE, LINK_DEV_RESOURCE (the Dynamics/Logic-App audience sent as resource=).

Per-country read credentials (config/services.php:35-70) — each org has the same six keys, prefixed EGYPT_CRM_*, OMAN_CRM_*, MONTENEGRO_CRM_*:

Key suffix Purpose
_AUTH_URL the org’s v2.0 token endpoint
_CLIENT_ID app-registration client id (same across orgs — see auth note)
_CLIENT_SECRET app-registration secret
_GRANT_TYPE defaults to client_credentials
_SCOPE the org’s Dynamics scope sent as scope=
_BASE_URL the org’s Dynamics host — prefix for {baseUrl}/api/data/v9.X/...

Oman-only extras: OMAN_CRM_PRE_REG_BROKER_REGISTER_PATH, OMAN_CRM_PRE_REG_BROKER_UPDATE_PATH (config paths appended to the base, not OData), plus the dev mock toggle OMAN_CRM_BROKER_CRM_MOCK_ENABLED and OMAN_CRM_BROKER_CRM_MOCK_BASE_URL.

Logic-App write endpoints (each a full URL supplied by env): LEAD_CRM_SUBMIT_NEW_LEAD_URL, LEAD_SHOPPER_SUBMIT_NEW_LEAD_URL, LEAD_CRM_SUBMIT_NEW_MEETING_URL, SALE_CRM_CREATE_DRAFT_SALE_URL, SALE_CRM_RESERVE_SALE_URL, SALE_CRM_ATTACH_SALE_URL, SALE_CRM_UPDATE_INFO_URL, SALE_CRM_GET_RESERVATION_FORM, UNIT_PAYMENT_TERMS_CRM_URL, UNIT_SALES_OFFER, UNIT_SUBMIT_ADDON, UNIT_SERVICE_SUBMIT, UNIT_RESELL_ASSISTANCE, CRM_BROKER_SELF_SIGNUP, SHOPPER_DETAILS_URL, CRM_EXCHANGE_RATE_URL, and the EOI/launches group CRM_API_EOI_SUBMIT_URL, CRM_API_EOI_GET_DETAILS_URL, CRM_API_EOI_GET_ATTACHMENTS_URL, CRM_API_EOI_CREATE_ATTACHMENT_URL, CRM_API_EOI_DELETE_ATTACHMENT_URL, CRM_API_EOI_PRODUCT_SUBMIT_URL, CRM_API_EOI_UNIT_TYPE_SUBMIT_URL, CRM_API_EOI_PREFERENCE_SUBMIT_URL, CRM_API_TASKEEN_EGT_URL.

SalesMan endpoints (services.crms.salesman.*): the shared read URLs SALESMAN_CRM_API_GET_URL and SALESMAN_CRM_API_SEARCH_URL (POST a query body with a per-country entityschemaname from the services.crms.salesman.entities map, config/services.php:100-158), plus the write URLs SALESMAN_CRM_API_SUBMIT_LEAD_URL, SALESMAN_CRM_API_SUBMIT_PIPELINE_URL, SALESMAN_CRM_API_SUBMIT_MEETING_URL, SALESMAN_CRM_API_SUBMIT_SALE_URL, SALESMAN_CRM_API_SUBMIT_UNIT_TYPE_URL, SALESMAN_CRM_API_CREATE_CUSTOMER_URL, SALESMAN_CRM_API_CREATE_ATTACHMENT_URL, SALESMAN_CRM_API_GET_LEAD_DETAILS_URL, SALESMAN_CRM_API_GET_CUSTOMER_DETAILS_URL, SALESMAN_CRM_USER_INFO_URL, SALESMAN_CRM_API_DISQUALIFY_LEAD_URL, SALESMAN_CRM_API_CHECK_ELIGIBLE_LEAD_URL, plus MICROSOFT_ADMIN_GUID (the admin system-user GUID stamped into sync queries as salesman_source_id).

Backend to middleware (backend/.env.example, backend config/services.php group crms_middleware): CRMS_MIDDLEWARE_BASE_URL, CRMS_MIDDLEWARE_AUTH_URL, CRMS_MIDDLEWARE_API_URL, CRMS_MIDDLEWARE_CLIENT_ID, CRMS_MIDDLEWARE_CLIENT_SECRET, CRMS_MIDDLEWARE_GRANT_TYPE — the Passport client_credentials client the backend uses to call /api/portals/*.

Middleware callback to backend (services.cms.*): CMS_BASE_URL, CMS_AUTH_URL, CMS_API_URL, CMS_CLIENT_ID, CMS_CLIENT_SECRET, CMS_GRANT_TYPE — how the middleware forwards inbound CRM webhooks (/api/crms/*) back into the backend.

  • resource= vs scope= is the whole distinction. If a write starts 401-ing, check the LINK_DEV_RESOURCE audience; if a read 401s, check that org’s _SCOPE. They are different Azure AD flows (v1.0 vs v2.0) against the same app, so a wrong audience/scope is the usual culprit, not a wrong client id.

  • A missing or unknown country header is a 500, not a 400. AppServiceProvider::getCountryFromHeadersOrFail() throws UnsupportedCountryException at container-resolution time; an operation not bound for that country throws UnsupportedClassForCountry (its render() returns 500). Every other upstream failure normalizes to a 400 — see the model page.

  • Auth failure yields an empty bearer, not an exception. AbstractClient::getAuthToken() returns '' when the token fetch is non-200 (app/Clients/AbstractClient.php:23-29); the call still goes out, now with an empty Authorization, and the CRM answers 401. The real cause (bad resource/scope/secret) hides one layer down.

  • One attempt, 300-second timeout, no retry. Every verb in AbstractClient sets a 300s connect+read timeout (app/Clients/AbstractClient.php:96-109) and there is no ->retry(...) anywhere — a failed Logic-App or Dynamics call is attempted exactly once.

  • UnitClient::createResale() reads a config key that does not exist. It calls config('services.crms.unit.create_resale'), but config/services.php has no create_resale key under crms.unit (:177-186) — even though UNIT_CREATE_RESALE is present in .env.example and is never wired up. The value resolves to null, so the resale write posts to an empty URL.

  • Some UnitClient writes are unauthenticated (getSalesOffer, getReservationForm, submitUnitAddon) — they use the bare post(), so no bearer is attached.

  • Not everything on the Link-Dev path is a write. SalesMan and EOI reads are Logic-App-fronted and POST a query body; they use LinkDevAuthenticatedClient and its resource= token. When auditing “which calls are writes,” go by the config key, not by the base class.

  • Never add a direct Dynamics call in the backend. Backend CLAUDE rule 3 forbids it and the is_published model (backend/adr/03-crm-integration.md) assumes all CRM data arrives via the middleware sync. A backend-side Dynamics call would bypass the credential boundary and the mapper layer.