Salesforce Outbound messages and Phoenix

Let’s sell some stuff!

Salesforce is an incredibly popular and powerful sales tracking system. With over 150k customers and an extensible platform, it’s a great opportunity to use our favorite programming language to get in on the action.

There are several libraries to access the SalesForce API like Jeff Weiss’ forcex, but not too much written about handling Outbound Messages.

Outbound Messages

Outbound Messages are webhooks that are triggered when a Salesforce object is updated. There are tutorials on how to setup the Salesforce side of this, so I won’t be covering it. Assume there’s an Outbound Messages set to trigger whenever the Stage of an Opportunity has been updated.

Getting the message

The first thing we need to do is build a way to accept the Outbound message from Salesforce. This may make some of you cringe but we’re going to have to accept… XML. Particularly, a SOAP response. If you don’t know what SOAP is, don’t worry about it. We’re going to treat this like regular XML.

Plug Parsers

Crack open your endpoint.ex file and you should see something like this:

1
2
3
4
  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

Under the covers, Plug comes with a few modules to handle json, multipart and url encoded requests. You can take a look at the code for urlencoded‘s parser here. Every parser needs 2 functions. an init/1 function for compiled configuration, and a parse/5 function. The parse/5 function returns a tuple with {:ok, params, conn} if it can parse this kind of request, or {:next, conn} if it can’t.

One parser that’s missing is an XML parser! No worries. We can handle all of our processing of the Outbound Message in the parser leaving our controller to code to deal with our nice, cleanly parsed data.

Let’s make a new Parser.

1
2
3
4
5
6
7
8
9
defmodule SFDCWebhookParser do
  def init(opts), do: opts
  def parse(conn, "text", "xml", _headers, opts) do
    {:ok, body, conn} = Plug.Conn.read_body(conn, opts)
    notifications = extract_from_webhook(body)
    {:ok, %{notifications: notifications}, conn}
  end
  def parse(conn, _type, _subtype, _headers, _opts), do: {:next, conn}
end

Here we have a parser that responds to messages with a content type of “text/xml” since we’re getting a SOAP message. We call Plug.Conn.read_body/2 in order to load request body. Then we pass it into our (as yet to be written) extract_from_webhook/1 function. In order to work on that… we’ll need to dive into parsing XML with SweetXml.

SweetXml

For this next bit, we’re going to need to talk about XPath. XPath is a way of traversing nodes in a tree. Take this HTML for instance:

1
2
3
4
5
6
7
8
9
10
html = """
<div>
  <ul edible="no">
    <li>One fish</li>
    <li>Two Fish</li>
    <li>Red Fish</li>
    <li>Blue Fish</li>
  </ul>
</div>
"""

Now we can use SweetXml to extract out some data from this markup and put in in a map for us.

1
2
3
4
5
6
7
8
import SweetXml
html
|> xpath(
~x"//ul", # From the root, find a ul node
 edible: ~x"@edible", # From that node, read its `edible` attribute
 items: ~x"./li/text()"l # Also from that node, find any li nodes and return their text. The `l` informs it we want a list back.
)
%{edible: 'no', items: ['One fish', 'Two Fish', 'Red Fish', 'Blue Fish']}

With this, we have enough to get data out of our Webhook.

Notification Message

The docs give an outline of the anatomy of a message. Here’s a sample message from the sandbox environment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
 <soapenv:Body>
  <notifications xmlns="http://soap.sforce.com/2005/09/outbound">
   <OrganizationId>00D5w000004qGTOEA2</OrganizationId>
   <ActionId>04k5w000000TSwMAAW</ActionId>
   <SessionId>...</SessionId>
   <EnterpriseUrl>...</EnterpriseUrl>
   <PartnerUrl>...</PartnerUrl>
   <Notification>
    <Id>04l5w00005286hAAAQ</Id>
    <sObject xsi:type="sf:Opportunity" xmlns:sf="urn:sobject.enterprise.soap.sforce.com">
     <sf:Id>0065w000023STAmAAO</sf:Id>
     <sf:AccountId>0015w00002BFDeLAAX</sf:AccountId>
     <sf:Amount>45000.0</sf:Amount>
     <sf:CloseDate>2020-07-11</sf:CloseDate>
     <sf:ContactId>0035w000035gg9GAAQ</sf:ContactId>
     <sf:CreatedById>0055w00000BhqR0AAJ</sf:CreatedById>
     <sf:CreatedDate>2020-05-08T13:40:22.000Z</sf:CreatedDate>
     <sf:FiscalQuarter>3</sf:FiscalQuarter>
     <sf:FiscalYear>2020</sf:FiscalYear>
     <sf:Follow_Up__c>false</sf:Follow_Up__c>
     <sf:HasOpenActivity>false</sf:HasOpenActivity>
     <sf:HasOpportunityLineItem>false</sf:HasOpportunityLineItem>
     <sf:HasOverdueTask>false</sf:HasOverdueTask>
     <sf:IsClosed>false</sf:IsClosed>
     <sf:IsDeleted>false</sf:IsDeleted>
     <sf:IsWon>false</sf:IsWon>
     <sf:LastModifiedById>0055w00000BhqR0AAJ</sf:LastModifiedById>
     <sf:LastModifiedDate>2020-05-09T03:04:32.000Z</sf:LastModifiedDate>
     <sf:LastReferencedDate>2020-05-09T03:06:37.000Z</sf:LastReferencedDate>
     <sf:LastViewedDate>2020-05-09T03:06:37.000Z</sf:LastViewedDate>
     <sf:LeadSource>External Referral</sf:LeadSource>
     <sf:Name>Backpackers, Inc. (Sample)</sf:Name>
     <sf:OwnerId>0055w00000BhqR0AAJ</sf:OwnerId>
     <sf:Probability>80.0</sf:Probability>
     <sf:StageName>Negotiation/Review</sf:StageName>
     <sf:SystemModstamp>2020-05-09T03:04:32.000Z</sf:SystemModstamp>
    </sObject>
   </Notification>
  </notifications>
 </soapenv:Body> 
</soapenv:Envelope>

It’s pretty beefy, but parsing will be pretty straight forward. The documentation states we may get up to 100 notification nodes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SweetXml
def extract_from_webhook(body) do
  body |> xpath(~x"//Notification"l, # Support for multiple notifications
  message_id: ~x"./Id/text()",
  type: ~x"./sObject/@xsi:type",
  object_id: ~x"./sObject/sf:Id/text()",
  account_id: ~x"./sObject/sf:AccountId/text()",
  amount: ~x"./sObject/sf:Amount/text()",
  close_date: ~x"./sObject/sf:CloseDate/text()",
  contact_id: ~x"./sObject/sf:ContactId/text()",
  created_by_id: ~x"./sObject/sf:CreatedById/text()",
  created_date: ~x"./sObject/sf:CreatedDate/text()",
  fiscal_quarter: ~x"./sObject/sf:FiscalQuarter/text()",
  fiscal_year: ~x"./sObject/sf:FiscalYear/text()",
  follow_up: ~x"./sObject/sf:Follow_Up__c/text()",
  has_open_activity: ~x"./sObject/sf:HasOpenActivity/text()",
  has_opportunity_line_item: ~x"./sObject/sf:HasOpportunityLineItem/text()",
  has_overdue_task: ~x"./sObject/sf:HasOverdueTask/text()",
  is_closed: ~x"./sObject/sf:IsClosed/text()",
  is_deleted: ~x"./sObject/sf:IsDeleted/text()",
  is_won: ~x"./sObject/sf:IsWon/text()",
  last_modified: ~x"./sObject/sf:LastModifiedById/text()",
  last_modified_date: ~x"./sObject/sf:LastModifiedDate/text()",
  last_reference_date: ~x"./sObject/sf:LastReferencedDate/text()",
  last_viewed_date: ~x"./sObject/sf:LastViewedDate/text()",
  lead_source: ~x"./sObject/sf:LeadSource/text()",
  name: ~x"./sObject/sf:Name/text()",
  owner_id: ~x"./sObject/sf:OwnerId/text()",
  probability: ~x"./sObject/sf:Probability/text()",
  stage_name: ~x"./sObject/sf:StageName/text()",
  system_modstamp: ~x"./sObject/sf:SystemModstamp/text()"
)
end

This will return the following as params:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
[
  %{
    account_id: '0015w00002BFDeLAAX',
    amount: '45000.0',
    close_date: '2020-07-11',
    contact_id: '0035w000035gg9GAAQ',
    created_by_id: '0055w00000BhqR0AAJ',
    created_date: '2020-05-08T13:40:22.000Z',
    fiscal_quarter: '3',
    fiscal_year: '2020',
    follow_up: 'false',
    has_open_activity: 'false',
    has_opportunity_line_item: 'false',
    has_overdue_task: 'false',
    is_closed: 'false',
    is_deleted: 'false',
    is_won: 'false',
    last_modified: '0055w00000BhqR0AAJ',
    last_modified_date: '2020-05-09T03:04:32.000Z',
    last_reference_date: '2020-05-09T03:06:37.000Z',
    last_viewed_date: '2020-05-09T03:06:37.000Z',
    lead_source: 'External Referral',
    message_id: '04l5w00005286hAAAQ',
    name: 'Backpackers, Inc. (Sample)',
    object_id: '0065w000023STAmAAO',
    owner_id: '0055w00000BhqR0AAJ',
    probability: '80.0',
    stage_name: 'Negotiation/Review',
    system_modstamp: '2020-05-09T03:04:32.000Z',
    type: 'sf:Opportunity'
  }
]

Woot! Now that map will be passed in params to your controller actions. There’s one more thing we need to make Salesforce happy.

The response.

Say we’re routing Outbound messages to SalesforceWeb.WebhookController.webhook/2. We’ll need to respond with a SOAP response to acknowledge the message.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
defmodule SalesforceWeb.WebhookController do
  use SalesforceDotComWeb, :controller

  def webhook(conn, params) do
    params |> do_cool_things()
    conn
    |> put_resp_content_type("text/xml")
    |> send_resp(200, acknowledgement())
  end

  def acknowledgement do
    """
    <?xml version="1.0" encoding="UTF-8"?>
        <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
            <soapenv:Body>
                <notificationsResponse xmlns="http://soap.sforce.com/2005/09/outbound">
                    <Ack>true</Ack>
               </notificationsResponse>
            </soapenv:Body>
        </soapenv:Envelope>
    """ |> String.trim()
  end
end

With this, we’re all set! NOTE: The acknowledgement will always be the same.

Next Steps

It’d be nice to have type conversion of strings to booleans and dates, but this is a great start. I hope this was informative.

Happy clacking!

comments powered by Disqus