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=.
Writes go through Azure Logic Apps
Section titled “Writes go through Azure Logic Apps”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 |
Reads hit Dynamics OData directly
Section titled “Reads hit Dynamics OData directly”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.1orv9.2in its URL, and the same operation uses the same version across all three orgs.v9.1is used by the unit-inventory readers (GetUnits,GetUnitsWithParams,GetResaleUnits,GetOccupations,GetUnitAddons);v9.2is 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 theldv_*dialect (ldv_units,ldv_countries). The full per-country entity-set matrix is indocs-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 |
The three country orgs
Section titled “The three country orgs”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.
Config / env
Section titled “Config / env”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.
Gotchas
Section titled “Gotchas”-
resource=vsscope=is the whole distinction. If a write starts 401-ing, check theLINK_DEV_RESOURCEaudience; 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
countryheader is a 500, not a 400.AppServiceProvider::getCountryFromHeadersOrFail()throwsUnsupportedCountryExceptionat container-resolution time; an operation not bound for that country throwsUnsupportedClassForCountry(itsrender()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 emptyAuthorization, and the CRM answers 401. The real cause (badresource/scope/secret) hides one layer down. -
One attempt, 300-second timeout, no retry. Every verb in
AbstractClientsets 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 callsconfig('services.crms.unit.create_resale'), butconfig/services.phphas nocreate_resalekey undercrms.unit(:177-186) — even thoughUNIT_CREATE_RESALEis present in.env.exampleand is never wired up. The value resolves tonull, so the resale write posts to an empty URL. -
Some
UnitClientwrites are unauthenticated (getSalesOffer,getReservationForm,submitUnitAddon) — they use the barepost(), so no bearer is attached. -
Not everything on the Link-Dev path is a write. SalesMan and EOI reads are Logic-App-fronted and
POSTa query body; they useLinkDevAuthenticatedClientand itsresource=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_publishedmodel (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.
