/** * @description Service class for Docusign REST API interactions * @author Paul Huliganga * @date 2026-02-23 */ public with sharing class DocusignAPIService { private static final Integer TIMEOUT_MS = 120000; // 120 seconds private static final Integer MAX_RETRIES = 2; /** * @description Creates a composite envelope in Docusign * @param envelopeJSON JSON string containing envelope definition * @param creds Docusign credentials object * @return Envelope ID (GUID) * @throws CalloutException if API call fails */ public static String createCompositeEnvelope( String envelopeJSON, DocusignCredentials creds ) { Long startTime = System.currentTimeMillis(); // Build HTTP request HttpRequest req = buildCreateEnvelopeRequest(envelopeJSON, creds); // Make callout with retry logic HttpResponse res = executeWithRetry(req, MAX_RETRIES); Long duration = System.currentTimeMillis() - startTime; // Log request/response (sanitized) logAPICall(req, res, duration); // Parse response if (res.getStatusCode() == 201) { return parseEnvelopeId(res); } else { handleAPIError(res); return null; // Won't reach here, handleAPIError throws } } /** * @description Builds HTTP request for creating envelope * @param envelopeJSON JSON body * @param creds Credentials * @return Configured HttpRequest */ @TestVisible private static HttpRequest buildCreateEnvelopeRequest( String envelopeJSON, DocusignCredentials creds ) { HttpRequest req = new HttpRequest(); // Use Named Credential if available, otherwise build URL manually String endpoint = buildEnvelopeEndpoint(creds); req.setEndpoint(endpoint); req.setMethod('POST'); req.setHeader('Authorization', 'Bearer ' + creds.getAccessToken()); req.setHeader('Content-Type', 'application/json'); req.setHeader('Accept', 'application/json'); req.setBody(envelopeJSON); req.setTimeout(TIMEOUT_MS); return req; } /** * @description Builds envelope endpoint URL * @param creds Docusign credentials * @return Full endpoint URL */ private static String buildEnvelopeEndpoint(DocusignCredentials creds) { // Check if using Named Credential (starts with 'callout:') if (creds.getBaseUrl().startsWith('callout:')) { return creds.getBaseUrl() + '/accounts/' + creds.getAccountId() + '/envelopes'; } else { return creds.getBaseUrl() + '/restapi/v2.1/accounts/' + creds.getAccountId() + '/envelopes'; } } /** * @description Executes HTTP request with retry logic * @param req HTTP request * @param maxRetries Maximum number of retry attempts * @return HTTP response * @throws CalloutException if all retries fail */ @TestVisible private static HttpResponse executeWithRetry(HttpRequest req, Integer maxRetries) { Http http = new Http(); HttpResponse res; Integer attempt = 0; while (attempt <= maxRetries) { attempt++; try { res = http.send(req); // Success or non-retryable error if (res.getStatusCode() == 201 || !isRetryableError(res.getStatusCode())) { return res; } // Rate limit - wait before retry if (res.getStatusCode() == 429 && attempt <= maxRetries) { Integer waitMs = calculateBackoff(attempt); System.debug('Rate limited, waiting ' + waitMs + 'ms before retry ' + attempt); // Note: Apex doesn't have Thread.sleep, but in production this would be handled by Platform Events // For now, just retry immediately } } catch (System.CalloutException e) { // Timeout or connection error if (attempt > maxRetries) { throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries: ' + e.getMessage()); } System.debug('Callout failed (attempt ' + attempt + '): ' + e.getMessage()); } } // All retries exhausted throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries. Last status: ' + res.getStatusCode()); } /** * @description Determines if an HTTP status code is retryable * @param statusCode HTTP status code * @return True if error is transient and should be retried */ private static Boolean isRetryableError(Integer statusCode) { // 401 (token expired), 429 (rate limit), 500-599 (server errors) return statusCode == 401 || statusCode == 429 || (statusCode >= 500 && statusCode < 600); } /** * @description Calculates exponential backoff delay * @param attempt Retry attempt number * @return Delay in milliseconds */ private static Integer calculateBackoff(Integer attempt) { // Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, ...) return (Integer) Math.pow(2, attempt) * 1000; } /** * @description Parses envelope ID from successful response * @param res HTTP response * @return Envelope ID (GUID) */ @TestVisible private static String parseEnvelopeId(HttpResponse res) { try { Map responseMap = (Map) JSON.deserializeUntyped(res.getBody()); String envelopeId = (String) responseMap.get('envelopeId'); if (String.isBlank(envelopeId)) { throw new CalloutException('Envelope ID not found in Docusign response'); } return envelopeId; } catch (Exception e) { throw new CalloutException('Failed to parse Docusign response: ' + e.getMessage()); } } /** * @description Handles API error responses * @param res HTTP response with error * @throws CalloutException with detailed error message */ @TestVisible private static void handleAPIError(HttpResponse res) { String errorMessage = 'Docusign API error [' + res.getStatusCode() + ']'; try { Map errorBody = (Map) JSON.deserializeUntyped(res.getBody()); String errorCode = (String) errorBody.get('errorCode'); String message = (String) errorBody.get('message'); if (String.isNotBlank(errorCode)) { errorMessage += ' ' + errorCode; } if (String.isNotBlank(message)) { errorMessage += ': ' + message; } } catch (Exception e) { // Could not parse error body, use raw response errorMessage += ': ' + res.getBody(); } // Map common errors to user-friendly messages errorMessage = enhanceErrorMessage(res.getStatusCode(), errorMessage); throw new CalloutException(errorMessage); } /** * @description Enhances error messages with user-friendly guidance * @param statusCode HTTP status code * @param originalMessage Original error message * @return Enhanced error message */ private static String enhanceErrorMessage(Integer statusCode, String originalMessage) { switch on statusCode { when 400 { return originalMessage + ' - Check template IDs and request parameters.'; } when 401 { return originalMessage + ' - Authentication failed. Check API credentials and access token.'; } when 403 { return originalMessage + ' - User lacks permission to create envelopes.'; } when 404 { return originalMessage + ' - One or more templates not found in Docusign account.'; } when 429 { return originalMessage + ' - API rate limit exceeded. Please try again later.'; } when 500, 503 { return originalMessage + ' - Docusign server error. Please try again.'; } when else { return originalMessage; } } } /** * @description Logs API call details (sanitized, no credentials) * @param req HTTP request * @param res HTTP response * @param durationMs Duration in milliseconds */ private static void logAPICall(HttpRequest req, HttpResponse res, Long durationMs) { System.debug(LoggingLevel.INFO, '=== Docusign API Call ==='); System.debug(LoggingLevel.INFO, 'Method: ' + req.getMethod()); System.debug(LoggingLevel.INFO, 'Endpoint: ' + sanitizeEndpoint(req.getEndpoint())); System.debug(LoggingLevel.INFO, 'Request Body Length: ' + req.getBody().length() + ' bytes'); System.debug(LoggingLevel.INFO, 'Response Status: ' + res.getStatusCode() + ' ' + res.getStatus()); System.debug(LoggingLevel.INFO, 'Response Body Length: ' + res.getBody().length() + ' bytes'); System.debug(LoggingLevel.INFO, 'Duration: ' + durationMs + 'ms'); // Only log full bodies in DEBUG mode (not in production) if (Test.isRunningTest()) { System.debug(LoggingLevel.FINEST, 'Request Body: ' + req.getBody()); System.debug(LoggingLevel.FINEST, 'Response Body: ' + res.getBody()); } } /** * @description Sanitizes endpoint URL for logging (removes account ID) * @param endpoint Full endpoint URL * @return Sanitized URL */ private static String sanitizeEndpoint(String endpoint) { // Replace account ID with placeholder for security return endpoint.replaceAll('/accounts/[a-f0-9\\-]+/', '/accounts/{accountId}/'); } /** * @description Custom exception for API errors */ public class CalloutException extends Exception {} }