August 16, 2026 · Tutorial
Salesforce Apex E-Signature Integration: REST Callouts & Webhooks (2026)
Stop paying $35 to $65 per user per month for bulky AppExchange packages. Learn how enterprise Salesforce developers and technical architects build high-performance, direct Apex REST callouts and HMAC webhook listeners using Named Credentials and Signbee's $0.50 flat API.
Founder, Signbee
TL;DR
AppExchange e-signature packages are a major cost and governor limit drain on enterprise Salesforce orgs. Instead of provisioning licenses for every Sales Cloud or Service Cloud user, you can connect Salesforce directly to the Signbee REST API with an Apex service class (SignbeeService.cls), secure Named Credentials, and a lightweight @RestResource webhook listener. You get instant signing ceremony URLs, seamless Salesforce Flow integration, automated Opportunity/Contract updates, and full 256-bit cryptographic audit trails at a flat $0.50 per envelope.
The Problem: The AppExchange E-Signature Tax
For more than a decade, enterprise Salesforce implementations have relied on monolithic AppExchange packages like DocuSign for Salesforce or Adobe Sign to execute sales agreements, non-disclosure agreements, and statements of work. While these managed packages provide point-and-click configuration, they present significant operational, architectural, and financial drawbacks:
- Per-Seat Extortion: Managed packages charge $35 to $65+ per user per month. In an enterprise org with 200 sales reps, account managers, and renewal specialists, your annual e-signature bill easily reaches $84,000 to $150,000+—even if half the team only dispatches one or two agreements per quarter.
- Governor Limit & Trigger Bloat: Commercial packages install dozens of custom objects (
dsfs__DocuSign_Status__c,dsfs__Envelope__c, etc.) and managed triggers that execute synchronously during record saves. This burns through Apex CPU time limits and SOQL query limits during high-volume data operations. - Rigid Visualforce & Template Builders: Legacy packages force administrators into clunky drag-and-drop template builders that cannot be version-controlled in Git, making CI/CD pipeline automation painful and error-prone.
- Vendor Lock-In & Sync Lag: Status updates frequently rely on scheduled polling jobs or brittle OAuth refresh token connections that fail silently when system admin credentials rotate.
By shifting to direct, native Apex REST callouts and webhooks, you eliminate managed package dependencies, achieve sub-second execution speeds, and reduce software licensing costs by over 90%. For teams evaluating broader API solutions, check out our comprehensive guide on the best e-signature APIs for developers in 2026 and our detailed breakdown of DocuSign vs. Signbee enterprise pricing.
Cost Analysis: $35/User/Month vs. $0.50 Flat API
Let's evaluate the total cost of ownership (TCO) across three typical Salesforce enterprise deployment tiers. In traditional per-seat licensing, you must purchase a seat for every user with access to the button, regardless of volume:
| Company Profile | AppExchange ($35/user/mo) | Signbee API ($0.50/doc) |
|---|---|---|
| Growth Org (25 users, 150 docs/mo) | $10,500/year | $900/year |
| Mid-Market (75 users, 400 docs/mo) | $31,500/year | $2,400/year |
| Enterprise (300 users, 1,200 docs/mo) | $126,000/year | $7,200/year |
With an API-first approach, your Salesforce instance is billed strictly for what it consumes. Every user in your org can trigger an agreement via Flow, Quick Action, or Apex without purchasing an additional add-on seat license.
Enterprise Architecture Overview
The integration follows an event-driven, decoupled architectural pattern conforming to Salesforce security best practices:
Salesforce Record (Opportunity / Contract) ➔ Apex Trigger / Flow ➔ SignbeeService.cls ➔ Named Credential (callout:Signbee_API/api/v1/send) ➔ Signbee API
Signbee delivers dynamic markdown document as rendered legal PDF to signer email or embedded iframe signing URL.
Signbee triggers POST to Salesforce Public Site endpoint ➔ SignbeeWebhookResource.cls ➔ Validates X-Signbee-Signature with HMAC-SHA256 ➔ Updates Opportunity Stage to "Closed Won" & attaches signed PDF as ContentVersion.
Step 1: Setting Up Salesforce Named Credentials
Never store API keys in Apex code, Custom Metadata, or Custom Settings. Salesforce Named Credentials handle endpoint definition and authentication securely, ensuring keys never leak into debug logs.
Configuration Steps in Salesforce Setup:
- Create an External Credential: Navigate to Setup ➔ Named Credentials ➔ External Credentials. Click New.
- Label:
Signbee_External - Name:
Signbee_External - Authentication Protocol: Custom
- Label:
- Add a Principal & Custom Header: Under Principals, add a principal named
Signbee_Principal. Add an authentication parameter:- Parameter Type: Auth Header
- Header Name:
Authorization - Value:
Bearer YOUR_SIGNBEE_API_KEY
- Create the Named Credential: Go to Named Credentials tab and click New:
- Label:
Signbee_API - Name:
Signbee_API - URL:
https://signb.ee - External Credential:
Signbee_External - Generate Authorization Header: Checked
- Label:
- Assign Permission Set: Grant your integration users or profiles access to the
Signbee_Externalprincipal via Permission Sets.
Step 2: The Apex Service Class (`SignbeeService.cls`)
The service class constructs the document payload using dynamic Markdown, performs the callout using the Named Credential, and handles errors with full telemetry. We also expose an @InvocableMethod so Salesforce Admins can invoke e-signatures directly from Salesforce Flow Builder with zero Apex knowledge.
public with sharing class SignbeeService {
public class SignbeeApiException extends Exception {}
// Request wrapper matching Signbee API contract
public class SendDocumentRequest {
public String markdown;
public String recipient_name;
public String recipient_email;
public String title;
public String redirect_url;
public String webhook_url;
public Map<String, String> metadata;
}
// Response wrapper
public class SendDocumentResponse {
public String document_id;
public String signing_url;
public String status;
public String error;
}
// Invocable Action input for Salesforce Flow Builder
public class FlowInput {
@InvocableVariable(label='Opportunity ID' required=true)
public Id opportunityId;
@InvocableVariable(label='Recipient Name' required=true)
public String recipientName;
@InvocableVariable(label='Recipient Email' required=true)
public String recipientEmail;
@InvocableVariable(label='Document Title' required=false)
public String documentTitle;
}
// Invocable Action output for Salesforce Flow Builder
public class FlowOutput {
@InvocableVariable(label='Document ID')
public String documentId;
@InvocableVariable(label='Signing URL')
public String signingUrl;
@InvocableVariable(label='Success')
public Boolean isSuccess;
@InvocableVariable(label='Error Message')
public String errorMessage;
}
/**
* Invocable method callable from Salesforce Flow
*/
@InvocableMethod(label='Send E-Signature via Signbee' description='Sends contract for electronic signature using Signbee API' category='E-Signature')
public static List<FlowOutput> sendContractFromFlow(List<FlowInput> requests) {
List<FlowOutput> results = new List<FlowOutput>();
for (FlowInput req : requests) {
FlowOutput out = new FlowOutput();
try {
// Fetch Opportunity & Account context
Opportunity opp = [
SELECT Id, Name, Amount, CloseDate, Description,
Account.Name, Account.BillingStreet, Account.BillingCity
FROM Opportunity
WHERE Id = :req.opportunityId
LIMIT 1
];
// Generate legal markdown document dynamically from CRM record
String markdown = generateContractMarkdown(opp, req.recipientName);
// Prepare API request
SendDocumentRequest apiReq = new SendDocumentRequest();
apiReq.title = String.isNotBlank(req.documentTitle) ? req.documentTitle : ('Agreement - ' + opp.Name);
apiReq.markdown = markdown;
apiReq.recipient_name = req.recipientName;
apiReq.recipient_email = req.recipientEmail;
apiReq.webhook_url = getSalesforceWebhookUrl();
apiReq.metadata = new Map<String, String>{
'opportunity_id' => String.valueOf(opp.Id),
'account_name' => opp.Account != null ? opp.Account.Name : ''
};
// Execute Callout
SendDocumentResponse apiRes = sendDocument(apiReq);
// Store metadata back onto Opportunity
opp.Signbee_Document_Id__c = apiRes.document_id;
opp.Signbee_Signing_URL__c = apiRes.signing_url;
opp.Signbee_Status__c = 'Out for Signature';
update opp;
out.documentId = apiRes.document_id;
out.signingUrl = apiRes.signing_url;
out.isSuccess = true;
} catch (Exception ex) {
out.isSuccess = false;
out.errorMessage = ex.getMessage();
System.debug(LoggingLevel.ERROR, 'Signbee Callout Failed: ' + ex.getMessage() + ' \n' + ex.getStackTraceString());
}
results.add(out);
}
return results;
}
/**
* Core HTTP Callout execution against Signbee API
*/
public static SendDocumentResponse sendDocument(SendDocumentRequest requestData) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Signbee_API/api/v1/send');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(120000); // 120 second timeout for enterprise reliability
String jsonBody = JSON.serialize(requestData, true);
req.setBody(jsonBody);
Http http = new Http();
HttpResponse res = http.send(req);
Integer statusCode = res.getStatusCode();
if (statusCode >= 200 && statusCode < 300) {
return (SendDocumentResponse) JSON.deserialize(res.getBody(), SendDocumentResponse.class);
} else {
String errDetail = 'Signbee API Error (' + statusCode + '): ' + res.getBody();
throw new SignbeeApiException(errDetail);
}
}
/**
* Helper to render dynamic markdown agreement from Salesforce Opportunity data
*/
public static String generateContractMarkdown(Opportunity opp, String signerName) {
String formattedAmount = opp.Amount != null ? ('$' + String.valueOf(opp.Amount.format())) : '$0.00';
String closeDateStr = opp.CloseDate != null ? String.valueOf(opp.CloseDate) : String.valueOf(Date.today());
String md = '# Master Services & Subscription Agreement\n\n';
md += '**Provider:** B2bee Ltd\n';
md += '**Client:** ' + (opp.Account != null ? opp.Account.Name : 'Client Organization') + '\n';
md += '**Signer:** ' + signerName + '\n';
md += '**Effective Date:** ' + closeDateStr + '\n\n';
md += '## 1. Engagement & Commercial Terms\n\n';
md += '| Term | Commitment |\n';
md += '| :--- | :--- |\n';
md += '| Total Contract Value | **' + formattedAmount + '** |\n';
md += '| Reference Opportunity | ' + opp.Name + ' (' + opp.Id + ') |\n';
md += '| Payment Schedule | Net 30 Days from Invoice Date |\n\n';
md += '## 2. Scope of Services\n\n';
md += (String.isNotBlank(opp.Description) ? opp.Description : 'Services defined in associated Statement of Work.') + '\n\n';
md += '## 3. Legally Binding Electronic Signature\n\n';
md += 'By signing electronically, both parties agree to all terms and conditions set forth herein under the provisions of the United States ESIGN Act and UETA regulations.';
return md;
}
/**
* Resolves the public Salesforce Site webhook URL from Custom Metadata or Settings
*/
private static String getSalesforceWebhookUrl() {
// Replace with your Salesforce Experience Cloud / Site Public Webhook URL
return 'https://yourdomain.my.site.com/services/apexrest/webhooks/signbee';
}
}Step 3: Inbound Webhook & Cryptographic HMAC Verification
When a recipient signs, views, or declines a document, Signbee issues an instant HTTPS POST request containing the event payload and an X-Signbee-Signature header.
In Salesforce, we expose a public REST resource using @RestResource(urlMapping='/webhooks/signbee/*') on a Salesforce Experience Cloud or Force.com Site. To prevent tampering, spoofing, and man-in-the-middle attacks, we verify the signature using the native Crypto.generateMac engine before executing any database DML operations.
@RestResource(urlMapping='/webhooks/signbee/*')
global without sharing class SignbeeWebhookResource {
// Store your webhook secret securely in a Protected Custom Metadata record
private static final String WEBHOOK_SECRET = Signbee_Settings__c.getInstance().Webhook_Secret__c;
public class WebhookEvent {
public String event; // e.g. 'document.signed', 'document.viewed', 'document.declined'
public WebhookData data;
}
public class WebhookData {
public String document_id;
public String status;
public String timestamp;
public String signed_pdf_url;
public Map<String, String> metadata;
}
@HttpPost
global static void handleWebhook() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
try {
// 1. Extract raw binary body and signature header
String signatureHeader = req.headers.get('X-Signbee-Signature');
Blob requestBodyBlob = req.requestBody;
String rawJson = requestBodyBlob != null ? requestBodyBlob.toString() : '';
// 2. Validate cryptographic signature
if (!verifyHmacSignature(requestBodyBlob, signatureHeader, WEBHOOK_SECRET)) {
System.debug(LoggingLevel.WARN, 'Signbee Webhook: Invalid HMAC Signature');
res.statusCode = 401;
res.responseBody = Blob.valueOf('{"error": "Unauthorized: Invalid Signature"}');
return;
}
// 3. Parse validated JSON payload
WebhookEvent evt = (WebhookEvent) JSON.deserialize(rawJson, WebhookEvent.class);
if (evt == null || evt.data == null) {
res.statusCode = 400;
res.responseBody = Blob.valueOf('{"error": "Malformed Event Payload"}');
return;
}
// 4. Dispatch processing based on event type
processEvent(evt);
res.statusCode = 200;
res.responseBody = Blob.valueOf('{"status": "success"}');
} catch (Exception ex) {
System.debug(LoggingLevel.ERROR, 'Webhook Processing Failed: ' + ex.getMessage() + ' \n' + ex.getStackTraceString());
res.statusCode = 500;
res.responseBody = Blob.valueOf('{"error": "' + String.escapeSingleQuotes(ex.getMessage()) + '"}');
}
}
/**
* Verifies the HMAC-SHA256 signature using Salesforce Crypto library
*/
public static Boolean verifyHmacSignature(Blob payloadBlob, String receivedSignature, String secretKey) {
if (payloadBlob == null || String.isBlank(receivedSignature) || String.isBlank(secretKey)) {
return false;
}
Blob secretBlob = Blob.valueOf(secretKey);
Blob calculatedMac = Crypto.generateMac('HmacSHA256', payloadBlob, secretBlob);
String calculatedHex = EncodingUtil.convertToHex(calculatedMac);
// Case-insensitive comparison against hex digest
return calculatedHex.equalsIgnoreCase(receivedSignature);
}
/**
* Business logic for updating Salesforce records
*/
private static void processEvent(WebhookEvent evt) {
String docId = evt.data.document_id;
String eventType = evt.event;
// Retrieve corresponding Opportunity by Document ID or metadata
String oppIdStr = (evt.data.metadata != null && evt.data.metadata.containsKey('opportunity_id'))
? evt.data.metadata.get('opportunity_id')
: null;
List<Opportunity> opps;
if (String.isNotBlank(oppIdStr)) {
opps = [SELECT Id, StageName, Signbee_Status__c FROM Opportunity WHERE Id = :oppIdStr LIMIT 1];
} else {
opps = [SELECT Id, StageName, Signbee_Status__c FROM Opportunity WHERE Signbee_Document_Id__c = :docId LIMIT 1];
}
if (opps.isEmpty()) {
System.debug(LoggingLevel.WARN, 'No matching Opportunity found for Document ID: ' + docId);
return;
}
Opportunity opp = opps[0];
switch on eventType {
when 'document.signed' {
opp.Signbee_Status__c = 'Completed';
opp.StageName = 'Closed Won';
opp.Signbee_Signed_Date__c = System.now();
update opp;
// Download & attach signed PDF to Salesforce Files asynchronously
if (String.isNotBlank(evt.data.signed_pdf_url)) {
System.enqueueJob(new AttachSignedPdfQueueable(opp.Id, evt.data.signed_pdf_url, 'Signed_Contract_' + docId + '.pdf'));
}
}
when 'document.viewed' {
opp.Signbee_Status__c = 'Viewed by Recipient';
update opp;
}
when 'document.declined' {
opp.Signbee_Status__c = 'Declined';
update opp;
}
}
}
}Step 4: Asynchronous PDF Attachment to Salesforce Files
When an agreement is signed, best practice is to download the completed PDF and attach it directly to the parent record as a Salesforce ContentVersion and ContentDocumentLink. Because making a secondary HTTP callout inside a synchronous webhook execution can exceed governor limits, we offload this to a lightweight Queueable:
public class AttachSignedPdfQueueable implements Queueable, Database.AllowsCallouts {
private Id parentRecordId;
private String pdfUrl;
private String fileName;
public AttachSignedPdfQueueable(Id recordId, String url, String name) {
this.parentRecordId = recordId;
this.pdfUrl = url;
this.fileName = name;
}
public void execute(QueueableContext context) {
try {
HttpRequest req = new HttpRequest();
req.setEndpoint(this.pdfUrl);
req.setMethod('GET');
req.setTimeout(60000);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
// 1. Create ContentVersion record
ContentVersion cv = new ContentVersion();
cv.Title = this.fileName;
cv.PathOnClient = this.fileName;
cv.VersionData = res.getBodyAsBlob();
cv.Origin = 'H';
insert cv;
// 2. Link to parent Opportunity
Id conDocId = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id].ContentDocumentId;
ContentDocumentLink cdl = new ContentDocumentLink();
cdl.ContentDocumentId = conDocId;
cdl.LinkedEntityId = this.parentRecordId;
cdl.ShareType = 'V';
cdl.Visibility = 'AllUsers';
insert cdl;
}
} catch (Exception ex) {
System.debug(LoggingLevel.ERROR, 'Failed to download and attach signed PDF: ' + ex.getMessage());
}
}
}Step 5: Enterprise Governor Limit Safeguards
Salesforce enforces strict governor limits that every technical architect must account for:
- Uncommitted Work Pending: In Apex, you cannot perform a DML statement (like
insertorupdate) prior to executing an HTTP callout within the same transaction. If your trigger needs to initiate a contract send, delegate the callout to an asynchronous@future(callout=true)method orSystem.enqueueJob(). - Callout Limit (100 per Transaction): While
SignbeeServiceis optimized for single-call transactions, batch automation over hundreds of records should leverage Batch Apex (Database.Batchable<sObject>, Database.AllowsCallouts) with a scope of 50 to 100 records. - Heap Size (12MB Synchronous / 6MB Callouts): Sending Markdown text strings generates negligible heap impact (a few kilobytes) compared to base64-encoding raw 50MB PDFs in Apex memory. Markdown document generation is inherently memory-efficient.
For more architectural patterns on handling webhooks and embedded signing flows in enterprise architectures, explore our in-depth SaaS e-signature integration guide.
Step 6: Writing Unit Tests (`SignbeeServiceTest.cls`)
Salesforce requires at least 75% code coverage for production deployment. Using HttpCalloutMock, we test both successful sends and error scenarios without executing live network requests:
@isTest
private class SignbeeServiceTest {
// Mock HTTP Responder
private class SignbeeMockSuccess implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
res.setBody('{"document_id":"doc_live_salesforce_123","signing_url":"https://signb.ee/sign/doc_live_salesforce_123","status":"sent"}');
res.setStatusCode(200);
return res;
}
}
@testSetup
static void setupTestData() {
Account acc = new Account(Name = 'Acme Enterprise Corp');
insert acc;
Opportunity opp = new Opportunity(
Name = 'Acme Global Expansion',
AccountId = acc.Id,
StageName = 'Proposal/Price Quote',
CloseDate = Date.today().addDays(30),
Amount = 50000.00,
Description = 'Enterprise multi-year platform licensing agreement.'
);
insert opp;
}
@isTest
static void testFlowCalloutSuccess() {
Opportunity opp = [SELECT Id FROM Opportunity LIMIT 1];
Test.setMock(HttpCalloutMock.class, new SignbeeMockSuccess());
SignbeeService.FlowInput input = new SignbeeService.FlowInput();
input.opportunityId = opp.Id;
input.recipientName = 'Jane Doe';
input.recipientEmail = 'jane.doe@acme.com';
input.documentTitle = 'Enterprise MSA';
Test.startTest();
List<SignbeeService.FlowOutput> outputs = SignbeeService.sendContractFromFlow(
new List<SignbeeService.FlowInput>{ input }
);
Test.stopTest();
System.assertEquals(1, outputs.size(), 'Should return one result');
System.assertEquals(true, outputs[0].isSuccess, 'Callout should succeed');
System.assertEquals('doc_live_salesforce_123', outputs[0].documentId);
Opportunity updatedOpp = [SELECT Signbee_Document_Id__c, Signbee_Status__c FROM Opportunity WHERE Id = :opp.Id];
System.assertEquals('doc_live_salesforce_123', updatedOpp.Signbee_Document_Id__c);
}
@isTest
static void testWebhookSignatureVerification() {
String secret = 'test_webhook_secret_key_123';
String payload = '{"event":"document.signed","data":{"document_id":"doc_live_salesforce_123","status":"signed"}}';
Blob payloadBlob = Blob.valueOf(payload);
Blob mac = Crypto.generateMac('HmacSHA256', payloadBlob, Blob.valueOf(secret));
String validSignature = EncodingUtil.convertToHex(mac);
Boolean isValid = SignbeeWebhookResource.verifyHmacSignature(payloadBlob, validSignature, secret);
System.assertEquals(true, isValid, 'Signature verification should pass for valid HMAC');
Boolean isInvalid = SignbeeWebhookResource.verifyHmacSignature(payloadBlob, 'invalid_sig', secret);
System.assertEquals(false, isInvalid, 'Signature verification should fail for invalid HMAC');
}
}Summary & Next Steps
By replacing rigid AppExchange packages with direct Apex REST callouts and HMAC-authenticated webhooks, your organization achieves:
Frequently Asked Questions
Why should enterprise Salesforce architects replace AppExchange packages with direct Apex REST callouts?
Traditional AppExchange e-signature packages impose a steep per-user seat license model, frequently costing $35 to $65 per user per month regardless of how many documents an individual actually sends. Beyond the financial overhead, AppExchange managed packages introduce substantial architectural debt: they install dozens of custom objects, heavy trigger frameworks that consume precious Apex CPU and SOQL query limits, and inflexible document builders that resist modern CI/CD source tracking. By transitioning to direct Apex REST callouts via Named Credentials and consumption-based APIs like Signbee ($0.50 flat per document), enterprise engineering teams gain full control over data residency, eliminate governor limit bottlenecks, streamline deployment pipelines, and reduce total cost of ownership by up to 90% without compromising legal validity or compliance under ESIGN, UETA, and eIDAS.
How do Named Credentials and External Credentials protect API keys in Salesforce Apex callouts?
Salesforce Named Credentials combined with modern External Credentials provide a secure, native abstraction layer that decouples endpoint URLs and sensitive authentication tokens from Apex codebase and metadata. Instead of hardcoding API keys in custom metadata types or custom settings—which risk accidental exposure in debug logs, source control repositories, or sandboxes—Named Credentials store authentication parameters in encrypted system storage. Apex developers simply reference the credential using the callout URL scheme (such as callout:Signbee_API/api/v1/send). The Salesforce platform automatically injects the Authorization header at runtime while preventing credential values from surfacing in debug logs, system exceptions, or unhandled callout traces, fulfilling critical enterprise security review requirements.
How is HMAC-SHA256 webhook signature verification safely implemented in an Apex REST Resource?
To ensure inbound webhook authenticity and prevent replay or spoofing attacks, an Apex REST Resource annotated with @RestResource(urlMapping='/webhooks/signbee/*') captures the raw binary request payload using RestContext.request.requestBody and inspects the X-Signbee-Signature HTTP header. Using the native Crypto.generateMac('HmacSHA256', payloadBlob, secretBlob) method, the Apex handler computes an HMAC digest using an encrypted webhook secret key and formats it into hexadecimal notation via EncodingUtil.convertToHex(). By performing a constant-time comparison against the received signature header before parsing JSON or executing any database DML operations, Salesforce organizations guarantee that status transitions (such as advancing an Opportunity to Closed Won or activating a Contract) only occur in response to verified cryptographic events.
Ready to replace costly AppExchange licenses with native Apex callouts?
Last updated: August 16, 2026 · Michael Beckett is the founder of Signbee and B2bee Ltd.