Duncan_Stewart | 2018-06-26 18:46:12 UTC | #1
We're looking at building a Platform API integration to change out the contact list for an outbound campaign on an ongoing basis. We have a regular campaign with the option to route the caller back into an inbound call flow, but the recipients likely change every time the campaign is run. I see a delete method in the list, but it looks to be on a contact-by-contact basis (vs. 'clear this list'), so it seems as though there are two primary options:
- Create a new Contact List; capture the Id
- Add contacts to the list, referencing the Id in the URL and the contact entries
- Update the campaign to use the new list
- Delete the old list
- trigger the campaign
or
- Query the existing campaign list, capture all the Contact Ids
- Delete all of the contacts from the list, using the Ids
- Add the new contacts to the list
- trigger the campaign
They don't seem all that different; retrieving the current list might be an option in either case, so that I can update the call status in Salesforce (just so I don't run out of things to do). Do these approaches make sense? Is one preferable to the other?
tim.smith | 2018-06-26 18:52:05 UTC | #2
Either option should work. I would probably lean towards the first, however. The second option Will take longer because you're likely to be rate limited on the deletes (300 requests per minute).
anon28066628 | 2018-06-26 20:33:57 UTC | #3
Both are options, but keep in mind the contact list ID is referenced by the campaign, rule sets, scripts, and outbound call flows (maybe others?). You can update the campaign and rule sets, but scripts and call flows are more challenging. The call flow would have to be republished after each list deletion. There is no API for updating flow contents, so it necessarily adds a manual operation.*
Depending on your situation deleting contacts may be better (and I do wish a "clear the list" endpoint was available). You can delete multiple at a time, up to 100 IIRC:
DELETE /api/v2/outbound/contactlists/{contactListId}/contacts
If you don't want to capture and track all IDs you can export the list before emptying it:
POST /api/v2/outbound/contactlists/{contactListId}/export
This is a heavy operation, so avoid doing it too frequently.
\--- * "so it necessarily adds a manual operation" - you actually can import, modify, and generate updated flows to be published with this library, though, it adds considerable complexity to integrations that use it.
Duncan_Stewart | 2018-06-26 20:40:40 UTC | #4
At the moment, the Contact List is only used by the campaign, so once the Campaign is done with it once, it (should be..) no longer needed. So the build/populate/replace/burn process (as tim suggested) should be fine.
The fun part will be actually connecting in the first place, of course; I'm piggybacking in on the standard Salesforce Integration for my inbound web services; reaching out is a new adventure!
Thanks for the suggestions!
Richard.Schott | 2018-06-29 15:30:49 UTC | #5
Duncan, have you looked at the campaign management feature that's now part of the PureCloud for Salesforce managed package? It can handle a lot of what you're trying to do, including returning the final disposition of all of the contacts in the contact list. It also leverages the standard Salesforce campaign object, meaning it ties into any business processes in Salesforce that add people to campaigns, as well as tying directly into out of the box Salesforce reports on opportunities/revenue generated from campaigns. As new campaign members are added to the campaign, the integration will dynamically update the contact list.
https://help.mypurecloud.com/articles/about-campaign-management-purecloud-salesforce/
Based on your comments, it might be worth a look.
Duncan_Stewart | 2018-06-29 16:33:23 UTC | #6
Interesting. We haven't been using Salesforce Campaigns for this process, as there's more overhead involved there (we've essentially created a lightweight Campaign Member clone); I certainly like the idea of the lower development overhead (though we would need to modify our current process to generate Campaigns, Campaign Members & Campaign Settings)..
I wasn't aware of this before, so thank you for the reference. If I can pull off the outbound connection from Salesforce to PureCloud in a reasonable amount of time, I should be in good shape. If not, this could do..
Duncan_Stewart | 2018-06-29 16:54:54 UTC | #7
Clarification request: Our campaign uses agentless dialing, pulls the phone numbers off of the assigned Contact list, and routes call responses to an outbound call flow. The call flow itself shouldn't have a reference to the Contact list, correct? We're passing over the Call.ANI and setting a Participant Data value, but the call flow wouldn't need to be republished in this scenario, would it?
Richard's suggestion to use the Campaign Management feature is tidy, except that I think we'd end up creating at least 1 campaign per day, while if we could maintain a single running PureCloud campaign & 'just' maintain the list, the overhead on both sides would be reduced.
(Deleting the file entries would take a few minutes; create & replace, if we didn't need to have a manual publish step involved, would be faster.)
anon28066628 | 2018-06-29 19:12:46 UTC | #8
Duncan_Stewart, post:7, topic:3069
The call flow itself shouldn't have a reference to the Contact list, correct?
It will - all outbound call flows reference a contact list and default wrap-up code (required fields when creating a new outbound flow). You would need to manually republish the outbound list (or update it using the Architect scripting library - not a light commitment). Even if the outbound flow does not reference or use the Contact information explicitly, deleting the the list without updating and republishing the flow will cause it to throw errors.
Duncan_Stewart | 2018-06-29 19:17:20 UTC | #9
I didn't run into any such requirement when creating this outbound flow: . In the Campaign definition, yes; in the call flow, no.
anon28066628 | 2018-07-02 13:16:38 UTC | #10
Are you certain? When I create an outbound flow, the contact list is a required parameter. Once created, removing the contact list creates a validation error.
https://www.screencast.com/t/eVXqn3njj
https://www.screencast.com/t/m3ocqu4H
Duncan_Stewart | 2018-07-02 14:00:28 UTC | #11
Okay, you got me -- holy frijole, that explains a few things... facepalm
So it's a delete-and-replace operation that we're going to be looking at; I think I can work with that. It's gonna be interesting, though :smiley:
While I plan to post a fully-fleshed out version when I'm done, here's the OAuth piece from Salesforce, anyway -- I can successfully connect (Yes!); now I have to start to do the work.
public class PureCloudUpdateCampaignList_queueable implements Queueable, Database.AllowsCallouts {
private List<SomeSObject_c> objects; private List<Id> objectIds;
public PureCloudUpdateCampaignList_queueable(List<Id> objectIds) { this.objectIds = objectIds; }
public void execute(QueueableContext context) {
// Retrieve the list of SObjects by their Ids, do something with them // related to the Campaign (presumably) objects = new List<SomeSObject_c> ([ Select Id, Name, Patient_c, Phone_c ZipCode_c from SomeSObject_c where Id in :this.objectIds]);
PureCloudConfig_c pureCloudConfig = PureCloudConfig_c.getOrgDefaults(); string clientId = pureCloudConfig.clientId_c; string clientSecret = pureCloudConfig.clientSecret_c;
Boolean updateSuccessful = false; // Make a POST request to PureCloud and retrieve the authToken HttpRequest req = new HttpRequest();
req.setMethod('POST');
req.setHeader('Authorization', 'Basic ' + EncodingUtil.base64Encode(Blob.valueOf(clientId + ':' + clientSecret))); req.setHeader('Host','https://login.mypurecloud.com/oauth'); req.setHeader('content-type', 'application/x-www-form-urlencoded'); req.setEndpoint('https://login.mypurecloud.com/oauth/token');
req.setBody('granttype=clientcredentials');
Http http = new Http();
try{ HTTPResponse res = http.send(req);
Utils.debug(res.toString(), 'REST Callout', 'PureCloudUpdateCampaign[57]'); Utils.debug('STATUS_CODE:'+res.getBody(), 'REST Callout', 'PureCloudUpdateCampaign[58]');
} catch(System.CalloutException e){ Utils.debug('Well, that didn\'t work...' + e.getMessage() + ',\n' + e.getCause() + ',\nLine: ' + e.getLineNumber(), 'REST Callout', 'PureCloudUpdateCampaign[61]'); }
The clientId & clientSecret are stored in a custom setting, and here's how to execute it from anonymous:
List<Id> objectIds = new List<Id> {'someSFDCId'}; PureCloudUpdateCampaignListqueueable pcucl = new PureCloudUpdateCampaignListqueueable(objectIds); Id jobId = System.enqueueJob(pcucl);
Those Utils.debug() calls write to a custom SObject which I use instead of sifting through System.debug logs.
Duncan_Stewart | 2018-07-02 15:01:35 UTC | #12
So here's another question -- why does the call flow reference a list? I've already run into this problem in test, and now I understand what happened: The campaign dialed contacts[0] in list1, and they selected the menu option that routed them to the outbound call flow, which then picked up contacts[0?] in list2, and passed that information to Salesforce, i.e. the wrong contact!
Even if both the Campaign and the Call Flow are referencing the same list, why should I assume that they're both referencing the same entry/position in the list?
If it's position-based, that should still work - contacts[0] > contacts[0], contacts[200] > contacts[200]. (It wouldn't be very useful if the Campaign was calling contacts[12], and then picking up contacts[0] over and over in the outbound flow, or something..)
anon28066628 | 2018-07-02 21:31:34 UTC | #13
I'm not sure of the architecture why it's designed that way, but I do know it's important that the call flow be republished with an updated contact list id reference if the campaign using the list transfers to that flow. The schema of a contact list is locked once created, and I assume the flow needs the list reference to access the fields at runtime. Note that even if the new list has the same name and field names, the lookups at runtime are based on internal GUIDs which will invariably be different even if the naming and schema are identical.
Duncan_Stewart | 2018-07-02 20:54:13 UTC | #14
Making more sense .. good. I'm working on the Contact List export, and I have your URL (POST /api/v2/outbound/contactlists/{contactListId}/export) in addition to one that's slightly different (POST /api/v2/outbound/contactlists/{contactListId}/bulk, which is listed in Developer Tools, but when I test it out with (what I thought was) the Id of my Contact List, I get a '400 Bad Request' error, even in Developer Tools.
The 'Id' I used was the section of URL following 'contactList/update/' when I open the contact list in Admin. Is that the Contact List.Id?
Here's the endpoint I'm using: https://api.mypurecloud.com/api/v2/outbound/contactlists/{' + contactListId + '}/export
anon28066628 | 2018-07-02 21:39:58 UTC | #15
Hi Duncan, yes the contact list id is in the URL of the Admin UI when editing a list, For example:
/outbound/admin/lists/contactLists/update/28161b6b-8ae7-48da-9c6a-b4808003d290
The first endpoint you should use is:
POST /api/v2/outbound/contactlists/{contactListId}/export
Which initiates export. Then GET on the same location:
GET /api/v2/outbound/contactlists/{contactListId}/export
This returns a download URL when the export is complete:
` { "uri": "https://api.mypurecloud.com/api/v2/downloads/169cd67b", "exportTimestamp": "2018-07-02T21:38:54.113Z" } `
Duncan_Stewart | 2018-07-03 13:35:56 UTC | #16
I'm now (so at least there's change!) getting a 'no protocol' response to my POST, which I'm thinking is due to messing up some other aspect of the request:
String authorizationHeader2 = 'Bearer ' + accessToken;
HttpRequest req2 = new HttpRequest();
req2.setMethod('POST'); req2.setHeader('Authorization', authorizationHeader2); req2.setHeader('Host','https://api.mypurecloud.com'); req2.setHeader('content-type', 'application/x-www-form-urlencoded'); req2.setEndpoint('/api/v2/outbound/contactlists/{\'' + contactListId + '\'}/export'); //alternatively, /bulk req2.setBody('granttype=clientcredentials');
Duncan_Stewart | 2018-07-09 18:30:26 UTC | #17
Assuming that the authorization header is correctly configured, is there another attribute that should go into the POST request in order for it to work correctly? I'm not familiar with the 'no protocol' response, and am not sure what my next step should be.
tim.smith | 2018-07-09 19:03:24 UTC | #18
What language are you using? Is that Apex?
I'm not sure about the no protocol error. Are you sure that the HttpRequest class sends a HTTPS request? The API is HTTPS only.
It looks like the path you're using will evaluate to /api/v2/outbound/contactlists/{'1234'}/export, if I'm reading that correctly. There shouldn't be curly braces or quotes in the path. It should be /api/v2/outbound/contactlists/1234/export.
It looks like the body was copied from your login request without changing it. Although this API is a POST request, it does not accept a request body. The content type should be application/json for all API requests unless documented otherwise; you may be able to leave it off this this case since there isn't a request body.
Duncan_Stewart | 2018-07-10 14:07:13 UTC | #19
Yes, it's Apex. I removed the braces, quotes, & request body and updated the content type. Still seeing the 'no protocol' error. Posting my request as it stands for reference; I'm going to cross-post on StackExchange to see what my Salesforce peers have to contribute.
String authHeader = 'Bearer ' + accessToken; HttpRequest req2 = new HttpRequest();
req2.setMethod('POST'); req2.setHeader('Authorization', authHeader); req2.setHeader('Host','https://api.mypurecloud.com'); //req2.setHeader('content-type', 'application/json'); req2.setEndpoint('/api/v2/outbound/contactlists/' + pureCloudCListId + '/export');
Http http2 = new Http();
try{ HTTPResponse res2 = http2.send(req2); updateSuccessful = true; } catch(System.CalloutException e){ Utils.debug('An error initializing Contact list export...' + e.getMessage() + ',\n' + e.getCause() + ',\nLine: ' + e.getLineNumber(), 'REST Callout', 'PureCloudUpdateCampaign[101]');
Duncan_Stewart | 2018-07-10 20:09:26 UTC | #20
I've rolled the Host portion into the endpoint and removed '.setHeader('Host.., but there's still an issue in there somewhere. At this point I think the issue remains on the Salesforce side. Here's where I am currently:
HttpRequest req2 = new HttpRequest();
req2.setMethod('POST'); req2.setHeader('Authorization', authHeader); req2.setEndpoint('https://api.mypurecloud.com/api/v2/outbound/contactlists/' + pureCloudCListId + '/export');
Http http2 = new Http();
try{ HTTPResponse res2 = http2.send(req2); updateSuccessful = true; } catch(System.CalloutException e){ Utils.debug('An error occurred initializing Contact list export...' + e.getMessage() + ',\n' + e.getCause() + ',\nLine: ' + e.getLineNumber(), 'REST Callout', 'PureCloudUpdateCampaignList_queueable[102]'); updateSuccessful = false; }
The authorization header appears to be correct - here's what I get when I output the authorizationHeader:
authHeader: Bearer <don't post auth tokens, treat them like passwords>
Just keeping you apprised...
Duncan_Stewart | 2018-07-10 20:34:19 UTC | #21
All right! I have a download URL! Now ... can I set that URL as a new endpoint and GET the file that was generated?
tim.smith | 2018-07-10 20:39:52 UTC | #22
I don't know how you retrieve a file's contents in Apex, but the standard approach is to make a GET request to the download URL. Be sure to also include the Authorization header on that request as well.
Would you mind posting the working code for anyone else who might be using Apex and comes across this thread?
Duncan_Stewart | 2018-07-10 21:01:03 UTC | #23
I plan to do that when it's all together -- I went ahead and (optimistically) assembled the GET request, which at the moment isn't failing, nor is it returning anything useful -- I have the Authorization header in place, but I think I probably need to use setBody('sometype=someformat(?)') to successfully retrieve the file.
But that's for tomorrow.
tim.smith | 2018-07-11 13:55:34 UTC | #24
GET requests don't have bodies. You might try the request in something like Postman to get it working in a more easily configurable environment to get a working baseline request and then replicate it in Apex.
Duncan_Stewart | 2018-07-13 18:39:18 UTC | #25
There are a number of steps involved:
- Requesting the OAuth token
- Initiating the Contact List export
- Retrieving the 'friendly URL' for the download file (this would work for a manual download)
- Using the friendly URL to get to the AWS file location
- Retrieving the actual data, which is a comma-separated string, not JSON
- Parsing the data (not shown)
I use a utility method, Utils.debug(), which creates records in SFDC that can be reviewed after the process is complete (vs poring over System.debug logs).
The clientId and clientSecret are managed in Salesforce Custom Settings in this example. Salesforce's JSON class has a number of methods for working with the responses, though their examples don't always fit this particular scenario.
// The initial request retrieves the OAuth2 authorization token HttpRequest req = new HttpRequest();
req.setMethod('POST'); req.setHeader('Authorization', 'Basic ' + EncodingUtil.base64Encode(Blob.valueOf(clientId + ':' + clientSecret))); req.setHeader('Host','https://login.mypurecloud.com/oauth'); req.setHeader('content-type', 'application/x-www-form-urlencoded'); req.setEndpoint('https://login.mypurecloud.com/oauth/token'); req.setBody('granttype=clientcredentials');
Http http = new Http(); String JSONString = null; try{ HTTPResponse res = http.send(req); JSONString = JSON.serialize(res.getBody()); // JSON.serialize() formats the data in comma-separated label:value pairs // "{\"accesstoken\":\"bigIndecipherableStr1ng23etc\",\"tokentype\":\"bearer\",\"expiresin\":86399}\n" updateSuccessful = true; } catch(System.CalloutException e){ Utils.debug(e.getMessage() + ',\n' + e.getCause() + ',\nLine: ' + e.getLineNumber(), 'REST Callout', 'PureCloudUpdateCampaign[63]'); }
if(updateSuccessful){
String accessToken = ''; String issuedAt = '';
//Parse JSON for Bearer Token JSONParser parser = JSON.createParser(JSONString); while (parser.nextToken() != null) { if (parser.getCurrentToken() == JSONToken.VALUESTRING && parser.getText().contains('accesstoken')) { accessToken = parser.getText(); } } // The access token is returned as a VALUESTRING, not a FIELDNAME: // JSONParser.getText() cleans it up some: //{"accesstoken":"bigIndecipherableStr1ng23etc","tokentype":"bearer","expiresin":86399}
String[] stringParts = accessToken.split('"'); accessToken = stringParts[3]; String authHeader = 'Bearer ' + accessToken; // Extract what we need and prepend 'Bearer ' to it: // 'Bearer bigIndeciphera_bleStr1ng23etc' HttpRequest req2 = new HttpRequest();
req2.setMethod('POST'); req2.setHeader('Authorization', authHeader); req2.setEndpoint('https://api.mypurecloud.com/api/v2/outbound/contactlists/' + pureCloudCListId + '/export');
Http http2 = new Http();
try{ HTTPResponse res2 = http2.send(req2); updateSuccessful = true; } catch(System.CalloutException e){
Utils.debug('An error occurred initializing Contact list export...' + e.getMessage() + ',\n' + e.getCause() + ',\nLine: ' + e.getLineNumber(), 'REST Callout', 'PureCloudUpdateCampaign[102]'); updateSuccessful = false; }
String sURL = ''; if(updateSuccessful){ sURL = getContactListURL(authHeader, pureCloudCListId); } String sNext = ''; if(updateSuccessful){ sNext = getContactList(authHeader, sURL); }
parseContactList(sNext); }
}
// GET /api/v2/outbound/contactlists/{contactListId}/export public String getContactListURL(String authHeader, String cListId){
String result = ''; HttpRequest req = new HttpRequest();
req.setMethod('GET'); req.setHeader('Authorization', authHeader); req.setHeader('content-type', 'application/json'); req.setEndpoint('https://api.mypurecloud.com/api/v2/outbound/contactlists/' + cListId + '/export');
Http http = new Http();
try{ HTTPResponse res = http.send(req); String sBody = res.getBody(); result = sBody; // which returns {"uri":"https://api.mypurecloud.com/api/v2/downloads/<fileName>","exportTimestamp":"2018-07-13T15:40:26.684Z"} updateSuccessful = true; } catch(System.CalloutException e){ Utils.debug('An error occurred retrieving the Contact list download URL...' + e.getMessage() + ',\n' + e.getCause() + ',\nLine: ' + e.getLineNumber(), 'REST Callout', 'PureCloudUpdateCampaign[137]'); updateSuccessful = false; }
return result; }
public String getContactList(String authHeader, String URL){
String result = ''; String tmpURL = ''; String JSONString = JSON.serialize(URL);
JSONParser parser = JSON.createParser(JSONString); while (parser.nextToken() != null) { if (parser.getCurrentToken() == JSONToken.VALUESTRING && //JSONToken.FIELDNAME && parser.getText().contains('uri')) { tmpURL = parser.getText(); } }
String[] stringParts = tmpURL.split('"'); tmpURL = stringParts[3]; // https://api.mypurecloud.com/api/v2/downloads/<fileName/> HttpRequest req = new HttpRequest();
req.setMethod('GET'); req.setHeader('Authorization', authHeader); req.setHeader('Content-Type', 'text/csv'); req.setEndpoint(tmpURL);
Http http = new Http(); Boolean locationSuccess = false; try{ HTTPResponse res = http.send(req); // 07.11: There is no response body, nor is there any content per se; // HeaderKey 'Location' returns the actual AWS location of the file, // but not the file itself. String[] headerkeys = res.getHeaderKeys(); String[] keyDetails = new String[]{};
for(String s : headerKeys){ keyDetails.add(res.getHeader(s)); } for(integer i=0;i<keyDetails.size();i++){ if(headerKeys[i] == 'Location'){ tmpURL = keyDetails[i]; locationSuccess = true; } } // Which returns the AWS location // https://prod-dialer.s3.amazonaws.com/contact-lists/exports/something_proprietary-PureCloud%20Inventory%20IVR%20Report%207.12b.csv?additionalParams
} catch(System.CalloutException e){ Utils.debug('An error occurred accessing the download URL...' + e.getMessage() + ',\n' + e.getCause() + ',\nLine: ' + e.getLineNumber(), 'REST Callout', 'PureCloudUpdateCampaign[196]'); locationSuccess = false; }
if(locationSuccess == true){
req = new HttpRequest();
req.setMethod('GET'); req.setHeader('Content-Type', 'text/csv'); req.setEndpoint(tmpURL);
http = new Http(); try{ HTTPResponse res = http.send(req); result = res.getBody();
} catch(System.CalloutException e){ Utils.debug('An error occurred accessing the download URL...' + e.getMessage() + ',\n' + e.getCause() + ',\nLine: ' + e.getLineNumber(), 'REST Callout', 'PureCloudUpdateCampaign[214]'); } }
// And here we see the actual data: // "inin-outbound-id","SFDC Record Name","Phone","First Name","Last Name","18 Digit Contact ID","18 Digit SFDC ID","etc" // "e60a7ff7cc2de18e23a0ceb37dfdb819","ivr-07/12/18-682719","(781) ..." // "938ddcefe9de99e5c2e54d95123368c1","ivr-07/12/18-683108","(978) ..." return result; }
system | 2018-08-13 18:39:18 UTC | #26
This topic was automatically closed 31 days after the last reply. New replies are no longer allowed.
This post was migrated from the old Developer Forum.
ref: 3069