Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

Guides

Authentication

Acrobat Sign accepts two kinds of credential: an Integration Key for your own account, and OAuth 2.0 tokens for accounts your users connect. This page helps you choose, then covers each in depth: the authorization-code flow, scopes, refresh, token storage, acting as another user, and what the SDK does to keep secrets out of logs.

Two ways to authenticate

Integration KeyOAuth 2.0
Whose accountYours. The key belongs to one user in one account.Your users'. Each user connects their own Acrobat Sign account.
Set-upCreate a key in Acrobat Sign, put it in an environment variable.Register an API application, implement a redirect and a callback, store tokens.
LifetimeUntil revoked.Access token: short-lived (Adobe says how long in expires_in). Refresh token: 60 days after its last use.
Data centreDiscovered with GET /baseUris and cached.Delivered with the token (api_access_point); no discovery call.
SDK entry pointAdobeSign::withIntegrationKey($key)AdobeSign::withOAuth($oauthClient, $tokenStore, $userKey)
Who paysYour Acrobat Sign plan.Each connecting user's plan.

Which one should I use?

  • Integration KeyYour company sends contracts from its own Acrobat Sign account: an HR system sending employment contracts, a CRM sending quotes, a back-office job. One credential, no user interaction. This covers most integrations.
  • OAuth 2.0You build a product that other organisations use, and each customer sends from their Acrobat Sign account and plan. Also the better choice when your security policy forbids long-lived shared keys, since every token is scoped and expires.
  • Key + asUser()One company account, but agreements must appear as sent by the right employee. An admin's key can act as any user in the account. See acting as another user.

Integration Key

An Integration Key is created in the Acrobat Sign web app (Account → Acrobat Sign API → API Information → Integration Key; see Getting started for the walkthrough). When you create it you choose its scopes; it then acts as the user who created it, with those permissions, until someone revokes it.

Using an Integration Key
use Conceptlz\AdobeSign\AdobeSign;

$sign = AdobeSign::withIntegrationKey((string) getenv('ADOBE_SIGN_INTEGRATION_KEY'));

// The same thing, spelled out:
use Conceptlz\AdobeSign\Auth\IntegrationKeyTokenProvider;

$sign = new AdobeSign(new IntegrationKeyTokenProvider((string) getenv('ADOBE_SIGN_INTEGRATION_KEY')));

The key is sent as a bearer token on every request. An empty or whitespace-only key throws InvalidArgumentException straight away, so a missing environment variable fails at start-up rather than at the first API call. A key Adobe rejects throws AuthenticationException (there is nothing to refresh, so the SDK doesn't retry).

Create the key as a dedicated user

Agreements sent with a key show its creator as the sender, and the key stops working if that person leaves and their user is deactivated. Many teams create a dedicated Acrobat Sign user (for example contracts@company.example) and generate the key as that user. Pair it with asUser() when individual employees should appear as senders.

OAuth 2.0

Acrobat Sign implements the standard OAuth 2.0 authorization-code flow. Your application sends the user to Adobe, the user logs in and approves the permissions you asked for, and Adobe sends them back to your site with a one-time code. Your server exchanges that code for an access token and a refresh token. From then on, the SDK uses the access token and refreshes it when it runs out.

Four classes in Conceptlz\AdobeSign\Auth do the work:

ClassJob
OAuthConfigYour application's client id, secret and redirect URI, plus Adobe's endpoints and the domains a token may come from.
OAuthClientThe flow itself: build the authorization URL, validate the callback, exchange the code, refresh, revoke.
TokenStoreAn interface you implement to persist each user's AccessToken. InMemoryTokenStore ships for scripts and tests.
OAuthTokenProviderPlugs a stored token into AdobeSign and refreshes it when needed. AdobeSign::withOAuth() builds it for you.

Register an API application

  1. Create the application

    In Acrobat Sign, open Account → Acrobat Sign API → API Applications and add one. Adobe shows you its Application ID (the OAuth client id) and Client Secret.

  2. Configure OAuth

    Choose Configure OAuth for Application. Enter your redirect URI exactly as your code will send it (scheme, host, port and path must match), and enable each scope your code will request, with the widest modifier you will need (self, group or account).

  3. Store the secret

    Keep the client secret on the server, in your secrets manager or environment. It never goes to the browser.

OAuthConfig

new OAuthConfig(string $clientId, string $clientSecret, string $redirectUri, string $authorizationUrl = 'https://secure.adobesign.com/public/oauth/v2', string $tokenHost = 'https://api.adobesign.com', array $allowedDomains = ['adobesign.com', 'echosign.com', 'adobesigngov.us'])

Validates itself on construction: an empty client id or secret, or a redirect URI that is neither HTTPS nor http://localhost / http://127.0.0.1, throws InvalidArgumentException. $authorizationUrl is Adobe's shard-neutral login page; Adobe routes each user to their own data centre. $tokenHost is used for the code exchange only when the callback carries no api_access_point. $allowedDomains limits where the SDK will send your client secret (see api_access_point).

The authorization-code flow

User's browser Your app Adobe sign-in secure.adobesign.com Adobe API api.<shard>… 1. "Connect Acrobat Sign" 2. generateState() → session 3. 302 to authorizationUrl() 4. log in, approve the scopes 5. 302 to redirect_uri with code, state, api_access_point 6. GET /callback?code=… 7. handleCallback(): state, access point 8. POST /oauth/v2/token (code + secret) 9. access + refresh token 10. TokenStore::put() 11. API calls · refresh before expiry
Steps 2, 3, 7, 8 and 10 are your code; the SDK provides each piece. The client secret only ever travels server to server (step 8), and only to a host on an allowed Adobe domain.

In code, the flow is two small endpoints. Here it is in plain PHP; any framework's controller looks the same.

connect.php: steps 2 and 3
use Conceptlz\AdobeSign\Auth\OAuthClient;
use Conceptlz\AdobeSign\Auth\OAuthConfig;

session_start();

$oauth = new OAuthClient(new OAuthConfig(
    clientId: (string) getenv('ADOBE_SIGN_CLIENT_ID'),
    clientSecret: (string) getenv('ADOBE_SIGN_CLIENT_SECRET'),
    redirectUri: 'https://app.example.com/adobe-sign/callback',
));

// A random, unguessable value tying the callback to this browser session (CSRF protection)
$_SESSION['adobe_sign_state'] = OAuthClient::generateState();

header('Location: ' . $oauth->authorizationUrl(
    ['user_login:self', 'agreement_read:self', 'agreement_write:self', 'webhook_read:self', 'webhook_write:self'],
    $_SESSION['adobe_sign_state'],
));
exit;
callback.php: steps 7 to 10
use Conceptlz\AdobeSign\Exception\OAuthException;

session_start();
$expected = $_SESSION['adobe_sign_state'] ?? '';
unset($_SESSION['adobe_sign_state']);                 // a state value is single-use

try {
    $token = $oauth->handleCallback($_GET, $expected); // checks state, then exchanges the code
    $tokenStore->put((string) $currentUser->id, $token);
    header('Location: /settings/integrations?connected=1');
} catch (OAuthException $e) {
    // access_denied (user clicked "Cancel"), STATE_MISMATCH, MISSING_CODE, or Adobe rejected the code
    error_log('Acrobat Sign connect failed: ' . $e->errorCode);
    header('Location: /settings/integrations?error=' . urlencode((string) $e->errorCode));
}
exit;

And anywhere afterwards, for that user:

Using the stored token
use Conceptlz\AdobeSign\AdobeSign;

$sign = AdobeSign::withOAuth($oauth, $tokenStore, (string) $currentUser->id);

foreach ($sign->agreements()->all(pageSize: 20) as $agreement) {
    echo $agreement->status, '  ', $agreement->name, "\n";
}
OAuthClient::generateState(): string
authorizationUrl(array $scopes, string $state): stringGET https://secure.adobesign.com/public/oauth/v2

generateState() returns 64 random hex characters. authorizationUrl() builds the URL with response_type=code, your client id, redirect URI, space-separated scopes and the state. It throws InvalidArgumentException for an empty scope list or an empty state: the SDK won't let you skip CSRF protection.

handleCallback(array $query, string $expectedState): AccessToken
exchangeCode(string $code, ?string $apiAccessPoint = null): AccessTokenPOST /oauth/v2/token

handleCallback() takes the callback's query parameters ($_GET or your framework's equivalent) and, in order: turns an error parameter into OAuthException with Adobe's code (for example access_denied); compares the state in constant time (STATE_MISMATCH); requires a code (MISSING_CODE); then calls exchangeCode() on the callback's api_access_point. Codes are valid for five minutes, so exchange them straight away. Use exchangeCode() directly only if you validate the callback yourself.

refresh(AccessToken $token): AccessTokenPOST /oauth/v2/refresh
revoke(AccessToken $token): voidPOST /oauth/v2/revoke

You rarely call refresh() yourself; OAuthTokenProvider does. A token without a refresh token throws OAuthException with code NO_REFRESH_TOKEN. revoke() revokes the pair (revoking either token revokes both); call it when a user disconnects your app, then delete the stored token.

Scopes

A scope is a permission the user grants your app. Each is a name plus an optional modifier: :self (the user's own agreements, and the default when you leave the modifier out), :group (everything in the user's group) or :account (the whole account). Group and account modifiers only work when an administrator of that group or account authorises the app, and your application's OAuth configuration must allow the scope and modifier.

ScopeNeeded for (SDK methods)
user_loginSigning the user in during the flow; Adobe's own examples always include it.
agreement_readagreements()->get, status, all, members, events, documents, combinedDocument, auditTrail
agreement_writetransientDocuments()->upload, agreements()->create, update, updateState, cancel, sendReminder, signingUrls
webhook_read, webhook_writewebhooks()->all and get; create, update, delete
library_read, library_writelibraryDocuments() reads; libraryDocuments()->create and updates
user_readusers()->me, get, all
widget_read, widget_writewidgets() (web forms)

Adobe's spec lists the scopes each endpoint accepts. Some older guides, and the Laravel bridge's defaults, also request agreement_send; requesting it does no harm if your application allows it. Ask for the least you need: users see the list on Adobe's consent screen, and a narrow request is easier for their administrators to approve.

Expiry and automatic refresh

OAuthTokenProvider sits between AdobeSign and your TokenStore. Before each request it reads the user's token and checks its expiry:

  1. Still valid

    If the token expires more than 60 seconds from now (the default leeway), it is used as is.

  2. About to expire

    Otherwise the provider calls refresh(), writes the new token back with TokenStore::put(), and uses it.

  3. Rejected anyway

    If Adobe still answers 401 (a revoked or clock-skewed token), the SDK invalidates it, re-reads the store in case another process already refreshed, refreshes, and retries the request once.

  4. Refresh impossible

    If refreshing fails (refresh token unused for 60 days, revoked, or the user's account changed), you get OAuthException. The only fix is to send the user through the flow again.

Adobe doesn't rotate refresh tokens: the same refresh token keeps working, and the SDK keeps it unless a response carries a new one. That means two workers refreshing the same user at the same moment both end up with valid access tokens, so no locking is needed. If nothing is stored for the key at all, the provider throws OAuthException with code NO_TOKEN: a good moment to show the user a "Connect Acrobat Sign" button.

Tuning the provider
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Auth\OAuthTokenProvider;

// Refresh five minutes before expiry instead of one; pass your PSR-20 clock in tests
$provider = new OAuthTokenProvider($oauth, $tokenStore, key: 'user-42', refreshLeewaySeconds: 300);
$sign = new AdobeSign($provider);

api_access_point and data centres

When Adobe redirects back to you, the query string includes api_access_point, the API host of the user's data centre (for example https://api.eu1.adobesign.com/). The SDK exchanges the code there, stores it on the token (AccessToken::$apiAccessPoint), and sends every later call there too, so OAuth clients never call GET /baseUris. If that address ever stops working (INVALID_API_ACCESS_POINT), the SDK falls back to discovery once.

Why the access point is validated

The access point arrives in a URL that anyone can craft. If your code sent the code exchange (which includes your client secret) to whatever host the query string named, an attacker could harvest the secret with a forged callback link. So OAuthConfig::validateAccessPoint() only accepts HTTPS hosts on adobesign.com, echosign.com or adobesigngov.us (or the domains you pass as $allowedDomains), and rejects URLs with embedded credentials. Anything else throws InvalidArgumentException before a request is made.

Storing tokens: TokenStore

TokenStore is a two-method interface. The key is whatever identifies the connection in your application: a user id, a team id, a tenant id.

interface TokenStore
    get(string $key): ?AccessToken
    put(string $key, AccessToken $token): void

get() returns null when the user hasn't connected. put() is called after the callback and after every refresh, and must replace the old token. AccessToken::toArray() gives you access_token, refresh_token, expires_at (a Unix timestamp) and api_access_point; AccessToken::fromArray() reverses it.

InMemoryTokenStore keeps tokens for the life of the PHP process, which is right for CLI scripts and tests and wrong for web apps. A real store persists tokens encrypted: a refresh token is a long-lived credential to someone else's account. This one uses PDO and libsodium:

src/PdoTokenStore.php
use Conceptlz\AdobeSign\Auth\AccessToken;
use Conceptlz\AdobeSign\Auth\TokenStore;

/**
 * CREATE TABLE adobe_sign_tokens (owner VARCHAR(191) PRIMARY KEY, payload TEXT NOT NULL);
 */
final class PdoTokenStore implements TokenStore
{
    public function __construct(
        private readonly \PDO $db,
        #[\SensitiveParameter] private readonly string $encryptionKey, // SODIUM_CRYPTO_SECRETBOX_KEYBYTES (32) bytes
    ) {
    }

    public function get(string $key): ?AccessToken
    {
        $statement = $this->db->prepare('SELECT payload FROM adobe_sign_tokens WHERE owner = ?');
        $statement->execute([$key]);
        $payload = $statement->fetchColumn();
        if ($payload === false) {
            return null;
        }

        $raw = (string) base64_decode((string) $payload, true);
        $nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $json = sodium_crypto_secretbox_open(substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), $nonce, $this->encryptionKey);

        return $json === false ? null : AccessToken::fromArray(json_decode($json, true, flags: JSON_THROW_ON_ERROR));
    }

    public function put(string $key, AccessToken $token): void
    {
        $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $box = sodium_crypto_secretbox(json_encode($token->toArray(), JSON_THROW_ON_ERROR), $nonce, $this->encryptionKey);

        // REPLACE INTO works on MySQL and SQLite; use INSERT … ON CONFLICT on PostgreSQL
        $this->db->prepare('REPLACE INTO adobe_sign_tokens (owner, payload) VALUES (?, ?)')
            ->execute([$key, base64_encode($nonce . $box)]);
    }
}

Returning null when decryption fails means a rotated encryption key simply looks like "not connected" and the user reconnects, instead of your app crashing. The Laravel bridge ships an Eloquent store that encrypts with your APP_KEY.

Acting as another user: asUser()

Adobe lets a credential with account-admin rights act on behalf of any user in the same account by adding an x-api-user header. Agreements are then created as that user (they are the sender, the agreement is in their list, and replies go to them), and reads see that user's agreements.

asUser(string $user): AdobeSign

Accepts email:jane@example.com, userid:CBJCHBCAABAA…, or a bare email or user id (an @ means email, anything else a user id). Returns a new client; the original is unchanged, so it is safe to keep one admin client and derive per-user clients from it. Both share the HTTP pipeline, including the ETag memory. A blank value throws InvalidArgumentException.

Sending as the account manager who owns the deal
$admin = AdobeSign::withIntegrationKey((string) getenv('ADOBE_SIGN_INTEGRATION_KEY'));

$asOwner = $admin->asUser($deal->ownerEmail);           // x-api-user: email:owner@company.example
$agreementId = $asOwner->agreements()->create($agreement);

// Later, from the same admin client, as somebody else
$admin->asUser('userid:CBJCHBCAABAAexample')->agreements()->cancel($otherId, 'Superseded');

If the credential isn't allowed to act for that user, or the user doesn't exist in the account, Adobe answers with codes such as INVALID_X_API_USER_HEADER or INVALID_ON_BEHALF_OF_USER; the SDK maps both to AuthenticationException. With OAuth, the token needs :account (or :group) scopes granted by an administrator for this to work.

Writing your own TokenProvider

Both built-in providers implement TokenProvider, and you can too, for example to read the key from a secrets manager that rotates it.

A key that is read from a vault and re-read after rotation
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Auth\TokenProvider;

final class VaultTokenProvider implements TokenProvider
{
    private ?string $token = null;

    public function __construct(private readonly SecretsClient $vault)
    {
    }

    public function getAccessToken(): string
    {
        return $this->token ??= $this->vault->read('adobe-sign/integration-key');
    }

    // Called after a 401: forget the cached value so the next call reads the rotated key
    public function invalidate(): void
    {
        $this->token = null;
    }

    // Stable and NOT secret: keys the cached data-centre lookup
    public function cacheKey(): string
    {
        return 'vault_main_account';
    }
}

$sign = new AdobeSign(new VaultTokenProvider($vault));

After a 401, the SDK calls invalidate(), asks for the token again and retries the request once, but only if the token actually changed. A provider whose token already knows its data centre can also implement ProvidesApiAccessPoint (one method, apiAccessPoint(): ?string) to skip discovery, as OAuthTokenProvider does.

How the SDK protects credentials

Credentials leak most often through logs, error trackers and debug dumps. The SDK closes each of those paths:

  • #[\SensitiveParameter]On every parameter that carries a secret: the Integration Key (withIntegrationKey(), IntegrationKeyTokenProvider), the client secret (OAuthConfig), the authorization code (exchangeCode()), both tokens in AccessToken, and participant passwords. PHP replaces them with a placeholder in stack traces.
  • Masked dumpsAccessToken, OAuthConfig and IntegrationKeyTokenProvider define __debugInfo(), so var_dump() and most dumpers show ***.
  • Redacted exceptionsApiException keeps the failed request for debugging, with the Authorization header replaced by Bearer ***, whoever constructs it. OAuth errors keep the request without its body, which held the secret, code or refresh token.
  • Quiet loggingThe PSR-3 logger receives method, path, status and timing. Never headers, never bodies.
  • Hashed cache keysThe data-centre cache is keyed by a SHA-256 prefix of the key or of your token-store key, so the cache never holds a credential.
  • Checked redirectsState compared in constant time, redirect URIs must be HTTPS (or loopback for development), and secrets only go to allow-listed Adobe hosts.

What the SDK can't do for you

It can't stop your own code from logging $token->toArray(), which contains both tokens in clear text, or from committing a key to Git. Store tokens encrypted, give keys the narrowest scopes that work, and revoke credentials when people leave or a user disconnects.

Where next

Common questions

Should I use an Integration Key or OAuth?

An Integration Key if your application sends from your own organisation's Acrobat Sign account; OAuth if each of your customers connects their own account. If you're unsure, start with a key: the rest of your code doesn't change when you switch, only the line that builds the client.

Users get OAuthException after a couple of months. Why?

Adobe expires a refresh token that hasn't been used for 60 days. Users who don't trigger any API call for that long must connect again. Catch OAuthException where you build the client and show a reconnect prompt. If you need the connection to stay alive, make a cheap call (such as users()->me()) for each connected user from a scheduled job every few weeks.

Can I test OAuth on localhost?

Yes. OAuthConfig accepts http://localhost and http://127.0.0.1 redirect URIs; everything else must be HTTPS. Register the exact URI in your application's OAuth configuration. The package's examples/oauth-server.php runs the whole flow on php -S localhost:8080.

Why do I get STATE_MISMATCH on the callback?

The state in the callback doesn't match the one in the session. The usual causes: the session cookie didn't survive the round trip (a different domain or SameSite=Strict), the user started the flow twice in two tabs, or the session expired while they were on Adobe's page. Keep the session cookie SameSite=Lax on the callback's domain.

Can one application hold tokens for many Acrobat Sign accounts?

Yes. One TokenStore holds any number of tokens under different keys, and each AdobeSign::withOAuth(..., $key) client works with one of them. Tokens on different data centres are fine: each token carries its own access point.

How do I rotate an Integration Key without downtime?

Create the new key, deploy it, then revoke the old one. Keys don't expire on their own, so there is no deadline. If your deployment reads the key from a secrets manager at runtime, a custom TokenProvider whose invalidate() re-reads it picks up the new key on the first 401, as shown above.

asUser() fails with an authentication error. What's missing?

Acting for another user needs an account administrator's credential, and the target must be an active user in the same account. With OAuth, the admin must also have granted :account (or :group) scopes. Check the errorCode on the exception: INVALID_X_API_USER_HEADER points at the header value, INVALID_ON_BEHALF_OF_USER at the user.