Send a completed call Task to Chambr from a Queueable, and store the returned
app_url on the Task.
Set up a Named Credential
- External Credential: Setup, Named Credentials, External Credentials,
New. Name it
Chambr, authentication protocol Custom. - Add a principal (Named Principal) with an authentication parameter
Keyholding your API key. - Add a custom header
Authorizationwith the value{!'Bearer ' & $Credential.Chambr.Key}. - Named Credential: name
Chambr, URLhttps://api.staging.chambr.ai, the External Credential above. Turn Generate Authorization Header off and Allow Formulas in HTTP Header on. - Give the running user a permission set with External Credential Principal Access to the principal.
Send calls from Apex
Call_Transcript__c, Call_Type__c and Chambr_Call_Url__c are your own
custom fields (a Long Text Area holds up to 131,072 characters).
Owner.Email must be the rep's Chambr login email; under SSO it often is not.
If you cannot guarantee it, leave rep out and
send the call without a rep.
The job takes a list of Task ids and sends one per run, then chains to the next, so one callout runs per transaction.
public class ChambrSendCall implements Queueable, Database.AllowsCallouts {
public final List<Id> taskIds;
public final Integer attempt;
public ChambrSendCall(List<Id> taskIds) { this(taskIds, 1); }
public ChambrSendCall(List<Id> taskIds, Integer attempt) { this.taskIds = taskIds; this.attempt = attempt; }
public void execute(QueueableContext ctx) {
System.attachFinalizer(new ChambrRetry(taskIds, attempt));
Task t = [SELECT Id, CompletedDateTime, LastModifiedDate, Owner.Email, Owner.Name,
Call_Transcript__c, Call_Type__c, Who.Name
FROM Task WHERE Id = :taskIds[0]];
if (String.isBlank(t.Call_Transcript__c)) return; // nothing to score
Datetime occurred = t.CompletedDateTime != null ? t.CompletedDateTime : t.LastModifiedDate;
Map<String, Object> body = new Map<String, Object>{
'external_id' => String.valueOf(t.Id),
'provider' => 'salesforce',
// RFC 3339 with Z. A +0000 offset is rejected.
'occurred_at' => occurred.formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''),
'rep' => new Map<String, Object>{ 'email' => t.Owner.Email, 'name' => t.Owner.Name },
'transcript' => new Map<String, Object>{ 'text' => t.Call_Transcript__c }, // "Name: line" per turn
'metadata' => new Map<String, Object>{
'salesforce' => new Map<String, Object>{ 'task_id' => String.valueOf(t.Id) }
}
};
if (String.isNotBlank(t.Call_Type__c)) body.put('call_type', t.Call_Type__c); // e.g. 'discovery'
if (t.Who != null && String.isNotBlank(t.Who.Name)) {
body.put('participants', new List<Object>{
new Map<String, Object>{ 'role' => 'customer', 'name' => t.Who.Name }
});
}
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Chambr/v1/calls');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(body));
req.setTimeout(60000); // at least 60 s
HttpResponse res = new Http().send(req);
Integer status = res.getStatusCode();
if (status == 200 || status == 202) {
Map<String, Object> out = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
update new Task(Id = t.Id, Chambr_Call_Url__c = (String) out.get('app_url'));
} else if (status == 429 || status >= 500) {
// Safe to retry (idempotent on provider + external_id): the Finalizer re-enqueues.
throw new CalloutException('Chambr ' + status + ' ' + res.getHeader('X-Request-Id'));
} else {
// Other 4xx: fix the data. Do not retry unchanged.
System.debug(LoggingLevel.ERROR, 'Chambr ' + status + ': ' + res.getBody());
}
}
}
Enqueue one job per trigger or Flow run, with every completed Task id in it:
System.enqueueJob(new ChambrSendCall(new List<Id>(completedTaskIds)));.
A transaction can enqueue at most 50 jobs, so never enqueue one per Task in a
bulk update. If your transcripts use generic labels such as "Speaker 1", also
send transcript.rep_speaker.
Retry with a Finalizer
A Queueable cannot sleep, and a thrown exception alone retries nothing. The Finalizer runs after each job, including after an exception or a callout timeout. It retries the same Task with a delay of up to 10 minutes, then moves on to the next.
public class ChambrRetry implements Finalizer {
private final List<Id> taskIds;
private final Integer attempt;
public ChambrRetry(List<Id> taskIds, Integer attempt) { this.taskIds = taskIds; this.attempt = attempt; }
public void execute(FinalizerContext ctx) {
Boolean failed = ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION;
if (failed && attempt < 5) {
System.enqueueJob(new ChambrSendCall(taskIds, attempt + 1), Math.min(attempt * 2, 10)); // minutes
} else if (taskIds.size() > 1) {
List<Id> rest = taskIds.clone();
rest.remove(0);
System.enqueueJob(new ChambrSendCall(rest));
}
}
}
Test with HttpCalloutMock
Production deploys need test coverage. A minimal test:
@IsTest
private class ChambrSendCallTest {
private class Accepted implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(202);
res.setBody('{"id":"6f1c2b0e-8a4d-4c1e-9d55-0d6c7b1e2a90","status":"queued","app_url":"https://app.staging.chambr.ai/t/1/real-calls/2"}');
return res;
}
}
@IsTest static void storesTheAppUrl() {
Task t = new Task(Subject = 'Call', Call_Transcript__c = 'Sarah: Hi\nDana: Hello');
insert t;
Test.setMock(HttpCalloutMock.class, new Accepted());
Test.startTest();
System.enqueueJob(new ChambrSendCall(new List<Id>{ t.Id }));
Test.stopTest();
System.assertNotEquals(null, [SELECT Chambr_Call_Url__c FROM Task WHERE Id = :t.Id].Chambr_Call_Url__c);
}
}