Skip to content
Art2link ESB HomeDocumentationBlogContact
Updated May 5, 2026
Tutorials/API scenarios/Webhook bridge

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.

One complaint becomes one ticket CRM complaint logged here notice The bridge translate the wording CRM to helpdesk create Helpdesk one ticket raised Helpdesk down: complaint waits and is tried again, never lost

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:

EXPRESSION
{{Message.MessageType}} == "SupportCase"
CRM webhook API Listener map → SupportCase BUS API Caller map → helpdesk shape POST Helpdesk

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.

EXPRESSION
{{Message.MessageType}} == "TicketCreateFailed"
Expect duplicate webhooks. CRMs re-fire on their own timeouts, so the same case can arrive twice. Carry the CRM's case id through the map and let the helpdesk call be idempotent on it, create-or-update, never blind create.

Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.

Before you start. Create the Application this flow lives in, a Name plus a code-safe Namespace, here AcmeSupport, under Applications, and select it, so every artifact you create below lands inside it.
1
Step One
Create the message types
The four types

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:

NameFormatPurpose
CrmCaseRaisedJSONthe CRM’s webhook payload as it arrives
SupportCaseJSONthe canonical case the bus carries
HelpdeskTicketJSONthe helpdesk’s create-ticket shape
TicketCreateFailedJSONthe exception type the failure path publishes under (Step 7)

2
Step Two
Create the Authentications

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.

CRM webhook, for the API Listener
SettingValue
NameCrmToken
AdapterAPI Listener
DefinitionAPI Listener Token
Token (Partner Config)the secret; give it to the CRM to present on every webhook
Helpdesk token endpoint, for the API Caller

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.

SettingValue
NameHelpdeskBearer
AdapterAPI Caller
DefinitionAPI Bearer Token
Login URL / Refresh Token URLthe helpdesk’s token endpoint and its refresh endpoint
Token JSON Path / Refresh Token JSON Paththe paths into the endpoint’s response
Username / Passwordthe login the helpdesk issued
Alert mailbox, for O365 Mail
SettingValue
NameO365Ops
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secret (Graph AuthConfig)an app registration with Mail.Send granted

3
Step Three
Build the inbound map
Create the map

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 settingValue
NameCrmCaseToSupportCase
Source message typeCrmCaseRaised, from Step 1
Target message typeSupportCase, from Step 1
The sample payloads

The CRM’s webhook payload as it arrives, a CrmCaseRaised:

JSON
{
  "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:

JSON
{
  "caseId": "5003x00002Kb9",
  "title": "Pump unit leaking after install",
  "priority": "high",
  "customer": "Brams Outdoor BV",
  "contactEmail": "t.vries@bramsoutdoor.example",
  "source": "crm"
}
Author the XSLT

Maps are authored in XSLT 3.0 and run on Saxon HE 12.9:

XSLT 3.0
<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>

4
Step Four
Build the outbound map
Create the map

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 settingValue
NameSupportCaseToHelpdeskTicket
Source message typeSupportCase, from Step 1
Target message typeHelpdeskTicket, from Step 1
The target shape

The HelpdeskTicket the create-ticket endpoint expects:

JSON
{
  "external_ref": "5003x00002Kb9",
  "summary": "Pump unit leaking after install",
  "priority": 2,
  "requester_email": "t.vries@bramsoutdoor.example"
}
Author the XSLT
XSLT 3.0
<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>

5
Step Five
Create the webhook Listener
General

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.)

GeneralValue
AdapterAPI Listener
WayOne
Pair the credential

Open the adapter configuration (the button beside Adapter) and pair the port with the CRM credential, so every webhook must present it:

Adapter ConfigurationValue
Auth Config TypeStatic
Auth ConfigCrmToken, from Step 2
VerbPOST
Headersthe two rows below
Declare the routing headers
HeaderValueWhy
AppAcmeSupportthe Application’s namespace, narrows the request to this Application
PurposeCrmCasekeeps 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.

Set the fallback type

On the Publish step under the port’s Solicit stages, set Message Type Fallback to CrmCaseRaised, from Step 1.

Select the map

On the Map step, set Map Assignment to Typed and add one row, selecting the inbound map from Step 3:

Source Message TypeMapTarget Message Type
CrmCaseRaisedCrmCaseToSupportCaseSupportCase
Register the endpoint

Register the endpoint URL, with the two headers above, in the CRM’s webhook settings.


6
Step Six
Create the ticket port
General

Create a send port CreateTicket on the API Caller, one-way, subscribing on {{Message.MessageType}} == "SupportCase".

SettingValue
AdapterAPI Caller
WayOne
MethodPOST
URLthe helpdesk’s create-ticket endpoint
Pair the credential

Set Authentication to HelpdeskBearer, from Step 2.

Select the map

On the Map step, set Map Assignment to Typed and add one row, selecting the outbound map from Step 4:

Source Message TypeMapTarget Message Type
SupportCaseSupportCaseToHelpdeskTicketHelpdeskTicket

7
Step Seven
Wire the failure path
Set the Exception Message Type

On CreateTicket, set Exception Message Type to TicketCreateFailed, from Step 1, with generous retries first, SaaS outages pass.

Create the OpsAlert send port

Create the OpsAlert O365 Mail port subscribing on {{Message.MessageType}} == "TicketCreateFailed":

SettingValue
AuthenticationO365Ops, from Step 2
Fromnoreply@acme.example
Toops@acme.example
SubjectArt2link alert: {{Message.MessageType}}

8
Step Eight
Start the ports and test
Start in dependency order

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.

Turn on tracking

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

Raise a test case in the CRM, and confirm the ticket appears in the helpdesk with the fields mapped correctly.

Fire the webhook twice

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 it on purpose

Break the helpdesk credential and confirm the alert plus replay from tracking.

Turn bodies off in production. Enabled + Body is the most expensive tracking level, extra processing and database space, and it records every payload including successful runs. Once the flow is proven, set the ports to Only on Error instead: failures still capture the full body for diagnosis, while healthy runs are not recorded.