Webhook bridge
Acme runs two separate tools. The sales team works in a CRM, where customer complaints are first logged. The support team works in a helpdesk, where each complaint needs to become a ticket someone can pick up. The two tools were built by different vendors and do not speak to each other. When a customer logs a complaint, the CRM announces it by sending a small automatic notice, a webhook, to whatever address you give it. The goal is that every such complaint turns into exactly one helpdesk ticket, with the right title, customer, and priority filled in. The catch is that the two tools describe a case completely differently: different field names, different layouts, different ways of writing dates and severities. And if the helpdesk happens to be down for a moment, the complaint must not be lost, it has to wait and be tried again, never silently dropped between the two tools.
So something has to stand in the middle: catch the CRM’s notice, translate it into the helpdesk’s language, and create the ticket. That middle piece is the bridge, and neither the CRM nor the helpdesk has to change anything beyond pointing the webhook at a new address.
Here is how Art2link ESB builds the bridge. The piece that catches the CRM’s notice is an API Listener receive port, a receive port is an entry point that puts an incoming message onto the bus, the shared message backbone every flow publishes onto and subscribes from. The Listener runs in one-way mode: webhook providers want a fast 2xx acknowledgement and will retry on anything else, which is exactly the Listener’s acknowledge-on-publish behaviour. As the notice comes in it is classified as CrmCaseRaised (labelled with its message type) and run through an inbound map, a small transform that rewrites one message shape into another, producing a canonical SupportCase, so the bus carries Acme’s own model of a support case, not the CRM vendor’s.
The piece that creates the ticket is an API Caller send port, a send port is an exit that acts on a message from the bus, here calling the helpdesk’s ticket endpoint. It chooses which messages to act on with a subscription, a plain rule matched against messages on the bus, and it carries its own outbound map from SupportCase to the helpdesk’s create-ticket shape. An adapter is the connector that teaches a port how to speak to one kind of system, and this API Caller is paired with an API Bearer Token Authentication that holds the helpdesk’s login, authenticate at the boundary:
{{Message.MessageType}} == "SupportCase"
The canonical hop in the middle looks like ceremony for a two-system bridge, until the day the field-service tool also wants to hear about support cases. That is one more subscription on SupportCase, not a second bridge. It is the same investment logic as two channels, one order, pointed the other way.
When it fails. The seam to respect is the acknowledgement: once the Listener has returned 2xx, the CRM will never send that webhook again, from that moment the message is Art2link's responsibility. A helpdesk outage runs the API port's retries; exhaustion re-publishes the case as TicketCreateFailed, the O365 Mail operations subscription raises it, and the case waits in tracking for replay, no customer complaint evaporates between two SaaS tools.
{{Message.MessageType}} == "TicketCreateFailed"
Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.
Under the application’s Message types, create the four types this flow routes on. A message type is a name plus a format, no schema required:
| Name | Format | Purpose |
|---|---|---|
| CrmCaseRaised | JSON | the CRM’s webhook payload as it arrives |
| SupportCase | JSON | the canonical case the bus carries |
| HelpdeskTicket | JSON | the helpdesk’s create-ticket shape |
| TicketCreateFailed | JSON | the exception type the failure path publishes under (Step 7) |
Three external parties, three credentials. Under the application’s Authentications, the dialog asks for a Name, the Adapter it pairs with, and a Definition, the credential shape that adapter offers, with the Application preset; the Definition decides the config section that follows.
| Setting | Value |
|---|---|
| Name | CrmToken |
| Adapter | API Listener |
| Definition | API Listener Token |
| Token (Partner Config) | the secret; give it to the CRM to present on every webhook |
The helpdesk issues tokens from a login endpoint; the adapter logs in, extracts the token, and injects the Authorization header on every call, never set one by hand.
| Setting | Value |
|---|---|
| Name | HelpdeskBearer |
| Adapter | API Caller |
| Definition | API Bearer Token |
| Login URL / Refresh Token URL | the helpdesk’s token endpoint and its refresh endpoint |
| Token JSON Path / Refresh Token JSON Path | the paths into the endpoint’s response |
| Username / Password | the login the helpdesk issued |
| Setting | Value |
|---|---|
| Name | O365Ops |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | an app registration with Mail.Send granted |
Under the application’s Maps, create the inbound map. Vendor field names stop here; severity is lower-cased into the canonical priority, and the CRM’s case id is carried through:
| Map setting | Value |
|---|---|
| Name | CrmCaseToSupportCase |
| Source message type | CrmCaseRaised, from Step 1 |
| Target message type | SupportCase, from Step 1 |
The CRM’s webhook payload as it arrives, a CrmCaseRaised:
{
"event": "case.created",
"case": {
"id": "5003x00002Kb9",
"subject": "Pump unit leaking after install",
"severity": "High",
"account": "Brams Outdoor BV",
"reporter": "t.vries@bramsoutdoor.example"
}
}The canonical SupportCase the bus carries:
{
"caseId": "5003x00002Kb9",
"title": "Pump unit leaking after install",
"priority": "high",
"customer": "Brams Outdoor BV",
"contactEmail": "t.vries@bramsoutdoor.example",
"source": "crm"
}Maps are authored in XSLT 3.0 and run on Saxon HE 12.9:
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:output method="text"/> <xsl:template match="/"> <xsl:variable name="in" select="parse-json(.)"/> <xsl:value-of select="serialize( map { 'caseId': string($in?case?id), 'title': string($in?case?subject), 'priority': lower-case(string($in?case?severity)), 'customer': string($in?case?account), 'contactEmail': string($in?case?reporter), 'source': 'crm' }, map { 'method': 'json', 'indent': true() })"/> </xsl:template> </xsl:stylesheet>
The outbound map reshapes the canonical case into the helpdesk’s payload shape. external_ref carries the CRM id so a re-fired webhook updates the same ticket instead of creating a duplicate:
| Map setting | Value |
|---|---|
| Name | SupportCaseToHelpdeskTicket |
| Source message type | SupportCase, from Step 1 |
| Target message type | HelpdeskTicket, from Step 1 |
The HelpdeskTicket the create-ticket endpoint expects:
{
"external_ref": "5003x00002Kb9",
"summary": "Pump unit leaking after install",
"priority": 2,
"requester_email": "t.vries@bramsoutdoor.example"
}<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:output method="text"/> <xsl:template match="/"> <xsl:variable name="in" select="parse-json(.)"/> <xsl:value-of select="serialize( map { 'external_ref': string($in?caseId), 'summary': string($in?title), 'priority': (if ($in?priority = 'high') then 2 else 3), 'requester_email': string($in?contactEmail) }, map { 'method': 'json', 'indent': true() })"/> </xsl:template> </xsl:stylesheet>
Create a receive port CrmWebhook on the API Listener. (The dropdowns can also create types and maps inline; building the dependencies first keeps each dialog a selection.)
| General | Value |
|---|---|
| Adapter | API Listener |
| Way | One |
Open the adapter configuration (the button beside Adapter) and pair the port with the CRM credential, so every webhook must present it:
| Adapter Configuration | Value |
|---|---|
| Auth Config Type | Static |
| Auth Config | CrmToken, from Step 2 |
| Verb | POST |
| Headers | the two rows below |
| Header | Value | Why |
|---|---|---|
| App | AcmeSupport | the Application’s namespace, narrows the request to this Application |
| Purpose | CrmCase | keeps this Listener unique within the Application |
The shared API ingress routes on these plus the Authentication; the Purpose value keeps this Listener distinct from any other in the same Application, see order intake for the full rule. The CRM must be able to send custom headers on its webhook, most can; if one cannot, give that Listener its own Authentication so the App-plus-credential pair stays unique.
On the Publish step under the port’s Solicit stages, set Message Type Fallback to CrmCaseRaised, from Step 1.
On the Map step, set Map Assignment to Typed and add one row, selecting the inbound map from Step 3:
| Source Message Type | Map | Target Message Type |
|---|---|---|
| CrmCaseRaised | CrmCaseToSupportCase | SupportCase |
Register the endpoint URL, with the two headers above, in the CRM’s webhook settings.
Create a send port CreateTicket on the API Caller, one-way, subscribing on {{Message.MessageType}} == "SupportCase".
| Setting | Value |
|---|---|
| Adapter | API Caller |
| Way | One |
| Method | POST |
| URL | the helpdesk’s create-ticket endpoint |
Set Authentication to HelpdeskBearer, from Step 2.
On the Map step, set Map Assignment to Typed and add one row, selecting the outbound map from Step 4:
| Source Message Type | Map | Target Message Type |
|---|---|---|
| SupportCase | SupportCaseToHelpdeskTicket | HelpdeskTicket |
On CreateTicket, set Exception Message Type to TicketCreateFailed, from Step 1, with generous retries first, SaaS outages pass.
Create the OpsAlert O365 Mail port subscribing on {{Message.MessageType}} == "TicketCreateFailed":
| Setting | Value |
|---|---|
| Authentication | O365Ops, from Step 2 |
| From | noreply@acme.example |
| To | ops@acme.example |
| Subject | Art2link alert: {{Message.MessageType}} |
Everything you configure is live the moment you save it, there is nothing to deploy. Start the send ports first, CreateTicket and OpsAlert, then the receive port CrmWebhook.
Set Tracking to Enabled + Body on every port the flow touches, so you can walk each run step by step, with its message body, in tracking.
Raise a test case in the CRM, and confirm the ticket appears in the helpdesk with the fields mapped correctly.
Fire the same webhook twice (most CRMs have a re-deliver button) and confirm two POSTs arrive at the helpdesk, both carrying the same external_ref. Deduplication is the helpdesk’s side of the contract: it must upsert on external_ref, a prerequisite of this design, not something the flow configures.
Break the helpdesk credential and confirm the alert plus replay from tracking.