API Best Practices

Base URI

Details

Bad Practices Identified

Best Practices

Pseudo Code

Good Pattern

//Required OAuth Scopes**:
//Accepts ANY valid OAuth scope** - This endpoint accepts any valid OAuth access token regardless of scope. It is used to retrieve the correct API access point (base URI) for subsequent API calls.
//Should be called before making other API calls to ensure you're using the correct base URI
## Scenario 1: Partner Application (Multiple Accounts)

// Cache to store base URIs for different accounts
private Map<String, String> baseUriCache = new HashMap<>();

// Method to invoke API with base URI caching
public ApiResponse invokeApi(String apiEndpoint, Map<String, String> apiParams, String accountId) {
    // Check if base URI for the account is cached
    String apiAccessPoint = baseUriCache.get(accountId);

    if (apiAccessPoint == null) {
        // If not cached, invoke baseUri API to get the base URI
        apiAccessPoint = getBaseUriForAccount(accountId);
        // Cache the base URI for future use
        baseUriCache.put(accountId, apiAccessPoint);
    }

    // Perform the REST API call with the cached or newly retrieved base URI
    ApiResponse apiResponse = callRestApi(apiAccessPoint + apiEndpoint, apiParams);

    // Check if INVALID_API_ACCESS_POINT error is returned
    if (apiResponse.getError().equals("INVALID_API_ACCESS_POINT")) {
        // Reinvoke baseUri API to get correct access point
        apiAccessPoint = getBaseUriForAccount(accountId);
        // Update the cache with the new base URI
        baseUriCache.put(accountId, apiAccessPoint);
        // Retry API call with the updated access point
        apiResponse = callRestApi(apiAccessPoint + apiEndpoint, apiParams);
    }

    // Return final API response
    return apiResponse;
}


# This method:
# 1. Gets an access token for a user in the specified account
# 2. Calls the getBaseUri API endpoint with that access token
# 3. Returns the api_access_point from the response

private String getBaseUriForAccount(String accountId) {
    # Step 1: Get an access token for a user in this account
    # This typically involves:
    # - Using application credentials (client_id, client_secret) to authenticate
    # - Exchanging them for an access token via OAuth flow
    # Or using a service account token if available
    # For partner apps, you might have:
    # - A service account user per customer account
    # - Or use account-level tokens stored securely
    String accessToken = getAccessTokenForAccount(accountId);

    # Step 2: Call the getBaseUri API endpoint
    # GET /api/rest/v6/base_uris
    # Headers: Authorization: Bearer {accessToken}
    BaseUriResponse baseUriResponse = callGetBaseUriApi(accessToken);

    # Step 3: Extract and return the api_access_point
    # Response format: { "api_access_point": "https://api.na1.echosign.com",
    #                   "web_access_point": "https://secure.na1.echosign.com" }
    return baseUriResponse.getApiAccessPoint();
}

private String getAccessTokenForAccount(String accountId) {
    # Implementation depends on your authentication setup:
    # - OAuth 2.0 flow with client credentials
    # - Service account authentication
    # - Stored tokens (if securely managed)

    # Example: OAuth 2.0 client credentials flow
    # POST /oauth/v2/token
    # Body: grant_type=client_credentials&client_id={client_id}&client_secret={client_secret}
    #

    return oauthService.getAccessToken(accountId);
}

private BaseUriResponse callGetBaseUriApi(String accessToken) {
    # GET /api/rest/v6/base_uris
    # Headers:
    #   Authorization: Bearer {accessToken}

    # Response:
    #   {"api_access_point": "https://api.na1.echosign.com",
    #   "web_access_point": "https://secure.na1.echosign.com"}


    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "Bearer " + accessToken);

    return restTemplate.getForObject(
        "https://api.echosign.com/api/rest/v6/base_uris",
        BaseUriResponse.class,
        headers
    );
}


---

## Scenario 2: Customer Application (Single Identity)
#Customer applications typically work with a single user identity and don't need to manage multiple accounts. This simplifies the implementation significantly

private String cachedBaseUri = null;

public ApiResponse invokeApi(String apiEndpoint, Map<String, String> apiParams) {
    // Check if base URI is cached
    if (cachedBaseUri == null) {
        // If not cached, get the base URI using the current user's access token
        cachedBaseUri = getBaseUriForCurrentUser();
    }

    // Perform the REST API call with the cached or newly retrieved base URI
    ApiResponse apiResponse = callRestApi(cachedBaseUri + apiEndpoint, apiParams);

    // Check if INVALID_API_ACCESS_POINT error is returned
    if (apiResponse.getError() != null && apiResponse.getError().equals("INVALID_API_ACCESS_POINT")) {
        // Reinvoke baseUri API to get correct access point
        cachedBaseUri = getBaseUriForCurrentUser();
        // Retry API call with the updated access point
        apiResponse = callRestApi(cachedBaseUri + apiEndpoint, apiParams);
    }

    // Return final API response
    return apiResponse;
}


##  For customer apps, this is simpler:
## 1. Use the current user's access token (from OAuth login flow)
## 2. Call the getBaseUri API endpoint
## 3. Return the api_access_point

private String getBaseUriForCurrentUser() {
    // Step 1: Get the current user's access token
    // In customer apps, this is typically obtained during OAuth login:
    // - User authorizes the app
    // - App receives access token
    // - Token is stored in session or secure storage
    String accessToken = getCurrentUserAccessToken();

    // Step 2: Call the getBaseUri API endpoint
    // GET /api/rest/v6/base_uris
    // Headers: Authorization: Bearer {accessToken}
    BaseUriResponse baseUriResponse = callGetBaseUriApi(accessToken);

    // Step 3: Extract and return the api_access_point
    return baseUriResponse.getApiAccessPoint();
}

private String getCurrentUserAccessToken() {
    # In customer apps, this is typically:
    # - Retrieved from session storage after OAuth login
    # - Or from secure token storage
    # - Or from the OAuth token exchange flow

    return sessionManager.getAccessToken();
}
callGetBaseUriApi(String accessToken) {
    #Same as partner scenario.
}


---

## Key Differences Summary

| Aspect | Partner Application | Customer Application |
|--------|-------------------|---------------------|
| **Account Management** | Multiple accounts | Single identity |
| **Caching Structure** | `Map<String, String>` (accountId → baseUri) | Single `String` variable |
| **Access Token Source** | Per-account tokens (service accounts, stored tokens) | Current user's token (from OAuth login) |
| **Complexity** | Higher (needs account management) | Lower (single user context) |
| **Use Case** | ISVs, integrations managing multiple customers | End-user applications |

---

## Important Notes

1. **getBaseUriForAccount Implementation**:
- Must obtain an access token for a user in the target account
- The access token is used to call the `/api/rest/v6/base_uris` endpoint
- The response contains `api_access_point` which should be cached

2. **Error Handling**:
- Always handle `INVALID_API_ACCESS_POINT` errors
- This can occur if an account is migrated to a different shard
- When this error occurs, refresh the base URI and retry

3. **Token Management**:
- Partner apps: Need secure storage/retrieval of per-account tokens
- Customer apps: Typically use session-based token storage

4. **Cache Invalidation**:
- Consider implementing cache expiration/TTL
- Invalidate cache on `INVALID_API_ACCESS_POINT` errors
- May want to refresh cache periodically

Bad Pattern

public ApiResponse invokeApi(String apiEndpoint, Map<String, String> apiParams, String accountId) {
    // Always invoke baseUri API to get the base URI, even if it's already known
    String apiAccessPoint = getBaseUriForAccount(accountId);

    // Perform the REST API call with the retrieved base URI
    ApiResponse apiResponse = callRestApi(apiAccessPoint + apiEndpoint, apiParams);

    // Return final API response
    return apiResponse;
}

Transient Document

Details

Bad Practices Identified

Invocation of API with invalid parameters causing request failures

Best Practices

Pseudo Code

Good Pattern

**Required OAuth Scopes** (ANY ONE of the following is sufficient):
- `agreement_write` - Required for creating agreements (most common for file uploads)
- `agreement_sign` - Required for signing agreements
- `widget_write` - Required for creating widgets
- `library_write` - Required for creating library documents

**Version-Specific Scopes**:
- **API v5 and earlier**: Also accepts `agreement_send` scope (deprecated in v6+)
- **API v6+**: `agreement_send` scope is **NOT** accepted

**API Type-Specific Scopes** (additional scopes accepted for special API types):
- **PRIVATE API type**: Also accepts `workflow_write` scope
- **INTERNAL_PARTNER API type**: Also accepts `agreement_sign` and `user_write` scopes


// method to perform input parameter validation
public boolean validateAndUploadFile(String filePath, String fileName) {

    // Step 1: Validate File Name
    if (fileName is null OR fileName is empty) {
        System.out.println("Error: File name cannot be empty.");
        return false; // Exit if validation fails
    }

    if (fileName.length() > 255) {
        System.out.println("Error: File name exceeds 255 characters.");
        return false; // Exit if validation fails
    }

    // Step 2: Validate File
    File file = new File(filePath);  // Assume File is a valid object representing the file

    if (!file.exists() OR file is a directory) {
        System.out.println("Error: File does not exist or is a directory.");
        return false; // Exit if validation fails
    }

    if (file.length() == 0) {
        System.out.println("Error: File is empty.");
        return false; // Exit if validation fails
    }

    if (file.length() > MAX_FILE_SIZE) { // MAX_FILE_SIZE is predefined (e.g., 10MB)
        System.out.println("Error: File size exceeds the maximum allowed size.");
        return false; // Exit if validation fails
    }


    System.out.println("File is valid.");
    return true;

}

public void invokeRestApi() {
    String filePath = <filePath>;
    String fileName = <fileName>;

    // method executing client side validation on filePath and fileName
    // Do note, there can be more validation that need to performed. This is just a sample. Have a look at the Swagger documentation for more details.
    if(!validateAndUploadFile(filePath, fileName)) {
        return;
    }

    invokeTransientDocumentApi(filePath, fileName);
}

Bad Pattern

public void invokeRestApi() {
    String filePath = <filePath>;
    String fileName = <fileName>;

    invokeTransientDocumentApi(filePath, fileName);
}

Create Agreement

Details

Bad Practices Identified

Invocation of API with invalid parameters

Best Practices

Pseudo Code

Good Pattern

JSON:

{
"participantSetsInfo": [
    {
    "role": "SIGNER",
    "order": 1
    }
],
"name": "NDA",
"signatureType": "ESIGN",
"signatureFlow": "SENDER_SIGNATURE_NOT_REQUIRED"
"fileInfos": [
    {"transientDocumentId": "3AAABLblqZhXXXXXXXXXXXX"}
],
"state": "DRAFT"
}

JSON for IN_PROCESS state:

{
"name": "NDA",
"signatureType": "ESIGN",
"signatureFlow": "SENDER_SIGNATURE_NOT_REQUIRED",
"fileInfos": [
    {
    "transientDocumentId": "3AAABLblqZhXXXXXXXXXXXX"
    }
],
"state": "IN_PROCESS",
"participantSetsInfo": [
    {
    "role": "SIGNER",
    "memberInfos": [
        {
        "email": "end78183@adobe.com",
        "securityOption": {
            "authenticationMethod": "NONE"
        }
        }
    ],
    "order": 1
    }
]
}

Bad Pattern

{
"participantSetsInfo": [
    {
    "role": "SIGNER",
    "order": 1,
    "recipientSetMemberInfos": [
        { "email": "not-an-email" }
        ]
    }
],
"name": "Invalid NDA Example",
"message": "This will fail due to invalid data.",
"signatureType": "ESIGN",
"signatureFlow": "SENDER_SIGNATURE_NOT_REQUIRED"
"fileInfos": [
    {"transientDocumentId": "not-a-real-transient-id"}
],
"state": "DRAFT"
}

Agreement State

Details

It is important to know that the IN_REVISION agreement state supports the capability of modifying an in-flight agreement by removing recipients, but it is not an extension of the authoring state and does not offer the full range of agreement modifications that are possible when the agreement in the authoring state.

Bad Practices Identified

Best Practices

Pseudo Code

Good Pattern

public void manageAgreementState(String agreementId) {
    // Step 1: Get current agreement status
    String currentStatus = getAgreementStatus(agreementId);

    // Step 2: Handle state transitions based on current status
    switch (currentStatus) {
        case "DRAFT":
            // Transition DRAFT -> AUTHORING
            updateAgreementState(agreementId, "AUTHORING");

            // After transitioning to AUTHORING, update form fields
            updateFormFields(agreementId);

            // Transition AUTHORING -> IN_PROCESS
            updateAgreementState(agreementId, "IN_PROCESS");
            break;

        case "IN_PROCESS":
            // Agreement is in process, no further authoring allowed
            // Optionally, transition to COMPLETED or CANCELLED if needed
            // updateAgreementState(agreementId, "COMPLETED") or
            // updateAgreementState(agreementId, "CANCELLED");
            break;

        case "CANCELLED":
        case "COMPLETED":
            // No further actions allowed if the agreement is completed or canceled
            print("No further actions allowed. Agreement is either completed or cancelled.");
            break;

        default:
            print("Error: Unknown or invalid agreement status.");
            break;
    }
}

Bad Pattern

public void manageAgreementState(String agreementId) {

    // Violation: Trying to update form fields even though agreement might be in DRAFT or COMPLETED state
    addUpdateFormData(agreementId);

    // Violation: Invalid state transitions (e.g., COMPLETED -> IN_PROCESS)
    updateAgreementState(agreementId, "IN_PROCESS");

    // Violation: Trying to update form fields again in an invalid state
    addUpdateFormData(agreementId);
}

Signing URL

Details

This API is used to obtain Signing URL for the agreement created. Obtaining the signing Url is useful for hosted signing where you can load the signing URL in a browser window on a mobile device and get the agreement signed in person

Bad Practices Identified

Immediately invoking the Signing URL API right after the agreement is created may result in a 404 HTTP status code response. This occurs because the agreement may not yet be fully processed and available for signing

Best Practices

Pseudo Code

Good Pattern

**Required OAuth Scopes**:
- **API v6+**: Always requires `agreement_write` scope
- **API v5 and earlier**: Conditional scopes based on request parameters:
- If `authoringRequested` OR `sendThroughWeb` is `true`: requires `agreement_write`
- If both `authoringRequested` AND `sendThroughWeb` are `false`: requires `agreement_send`
- If `autoLoginUser` is `true`: additionally requires `user_login` scope
public String getSigningUrl() {

    // Step 1: Create Agreement
    String agreementId = invokeCreateAgreementAPI();

    // Step 2: Polling to check if the agreement is "OUT_FOR_SIGNATURE"
    // add backoffTime and maxRetries to handle the polling mechanism
    boolean isOutForSignature = false;
    int backoffTime = 1000; // Initial backoff time in milliseconds (1 second)
    int maxRetries = 5;
    int attempt = 0;

    while (!isOutForSignature && attempt < maxRetries) {
        // Poll agreement status
        String agreementStatus = pollAgreementStatus(agreementId);

        if (agreementStatus.equals("OUT_FOR_SIGNATURE")) {
            isOutForSignature = true;
        } else {
            // Exponential backoff
            try {
                Thread.sleep(backoffTime);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            backoffTime *= 2;  // Double the backoff time
            attempt++;
        }
    }

    if (isOutForSignature) {
            String signingUrl = invokeGetSigningUrlAPI(agreementId);
            print("Signing URL: " + signingUrl);
            return signingUrl;
        } else {
            print("Failed to get agreement status within retry limit.");
        }
    return null;
}

Bad Pattern

public String getSigningUrl() {
        // Step 1: Create Agreement
        String agreementId = invokeCreateAgreementAPI();
        // Step 2: Invoke getSigningUrl API to get the signing URL
        String signingUrl = invokeGetSigningUrlAPI(agreementId);

        return signingUrl;
}

Tracking Progress / Status of Agreement

Details

Bad Practices Identified

Best Practices

Pseudo Code

Good Pattern

public void handleAgreementNotification(Agreement agreement) {
    // Webhook received: update system based on agreement's new status
    boolean handleWebhookNotification = updateAgreementWithNewStatusFromWebhooks(agreement);

    if(handleAgreementNotification) {
        return;
    }

    // webhook was not processed due to miscellanous reasons
    // poll for agreement status

    // check if the current agreement status is non-terminal
    if(isTerminalState(agreement.getStatus())) {
        return;
    }

    startPollingForAgreementStatus(agreement);

}

public void startPollingForAgreementStatus(Agreement agreement) {
    int backoffTime = 1000; // Initial backoff time (in milliseconds)
    int maxBackoff = 60000; // Maximum backoff time
    String currentStatus;

    // Keep polling until the agreement reaches a terminal state
    while (true) {

        // Get the current agreement status
        currentStatus = getAgreementStatus(agreementId);

        // If agreement is in a terminal state, stop polling
        if (isTerminalState(currentStatus)) {

            terminatePolling(agreementId);
            break;

        }

        // Wait for the backoff time before next polling
        waitFor(backoffTime);

        // Increase the backoff time (exponential backoff)
        backoffTime = Math.min(backoffTime * 2, maxBackoff);

    }

}

Bad Pattern

public void handleAgreementNotification(Agreement agreement) {
    // Webhook received: update system based on agreement's new status
    boolean handleWebhookNotification = updateAgreementWithNewStatusFromWebhooks(agreement);

    if(handleAgreementNotification) {
        return;
    }
    // no fallback
    // return;

    startPollingForAgreementStatus(agreement);

}

public void startPollingForAgreementStatus(Agreement agreement) {
    String currentStatus;

    while (true) {
        currentStatus = getAgreementStatus(agreementId);

        // If agreement is in a terminal state, stop polling
        if (isTerminalState(currentStatus)) {
            terminatePolling(agreementId);
            break;
        }
}

Retrieving List of Agreements and Details

Details

Bad Practices Identified

Best Practices

Pseudo Code

Good Pattern

**Required OAuth Scopes**:
- `agreement_read` - Required to retrieve agreements for the user

@GET
Public List<UserAgreements> getAgreementsAndDetails(String query, String id, String group, String namespace, String cursor, String pageSize){...}

Bad Pattern

@GET
Public List<UserAgreements> getAgreementsAndDetails(String query, String id, String group, String namespace){...}

Retrieving List of Agreements of a Widget

Details

Retrieving List of Users and Details

Details

To retrieve list of users and user’s details, following two APIs are used

Bad Practices Identified

Best Practices

Pseudo Code

Good Pattern

public List<UserDetails> getUsersAndDetails(String query, String id, String group, String namespace, String cursor, String pageSize){...}

Bad Pattern

public List<UserDetails> getUsersAndDetails(String query, String id, String group, String namespace){...}

API Rate Limits and Retry

Details

{
    "code":"THROTTLING_TOO_MANY_REQUESTS",
    "message":"<error_message_with_wait_time> (apiActionId=<api_action_id>)"
    "retryAfter": <wait_time_in_seconds>
}

Retry-After Header

Retry-After: <wait_time_in_seconds> //minimum time in seconds the client must wait until it can make the next request

Bad Practices Identified

Best Practices

Pseudo Code

Good Pattern

function callSignApiWithRetry(request):

    MAX_RETRIES = 5
    BASE_BACKOFF_SECONDS = 2
    MAX_BACKOFF_SECONDS = 60

    attempt = 0

    while (attempt < MAX_RETRIES):

        response = executeHttpRequest(request)

        if (response.status == 200):
            return response.body

        if (response.status == 429):
            attempt++

            retryAfter = response.headers["Retry-After"]

            if (retryAfter exists):
                waitTime = retryAfter
            else:
                // Fallback exponential backoff
                waitTime = min(
                    BASE_BACKOFF_SECONDS * (2 ^ attempt),
                    MAX_BACKOFF_SECONDS
                )

            log.warn(
                "Sign API throttled. " +
                "Attempt={}, Waiting={} seconds",
                attempt, waitTime
            )

            sleep(waitTime)
            continue

        if (response.status is retryable 5xx):
            attempt++
            waitTime = min(
                BASE_BACKOFF_SECONDS * (2 ^ attempt),
                MAX_BACKOFF_SECONDS
            )

            sleep(waitTime)
            continue

        // Non-retryable errors (4xx except 429)
        throw ApiException(response)

    throw RetryLimitExceededException("Max retries exceeded")

Bad Pattern

//Hammering the API after 429 (no delay)
while hasMoreWork():
resp = http.post("/api/rest/latest/agreements", payload)
if resp.status == 429:
    // ignore Retry-After; keep going
    continue
handle(resp);
/**/

// Fixed-delay retries ignoring Retry-After and backoff
// for attempt in 1..infinite:
resp = http.get("/api/rest/v5/agreements?query=NDA")
if resp.status != 429:
    break
sleep(100ms) // too short; constant; ignores server guidance

© Copyright 2022, Adobe Inc.. Last update: Dec 18, 2025.