Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

Reference

Configuration

The SDK works with no configuration at all, but a production application usually wants a few things set: a shared cache for the data-centre lookup, a logger, sensible HTTP timeouts and maybe a project-wide signature level. This page lists every option, its default, and what it changes.

At a glance

Everything is passed to the AdobeSign constructor, or by name to one of its two factory methods. Every argument except the token provider is optional:

bootstrap/adobe-sign.php
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\ClientOptions;
use Conceptlz\AdobeSign\Enum\SignatureLevel;

$sign = AdobeSign::withIntegrationKey(
    getenv('ADOBE_SIGN_INTEGRATION_KEY'),
    httpClient: $psr18Client,      // default: auto-discovered
    requestFactory: $psr17Factory, // default: auto-discovered
    streamFactory: $psr17Factory,  // default: auto-discovered
    cache: $psr16Cache,            // default: an in-process cache that dies with the client
    logger: $psr3Logger,           // default: NullLogger
    options: new ClientOptions(
        maxRetries: 5,
        defaultSignatureLevel: SignatureLevel::AES,
    ),
);

Nothing is sent when you build the client. The first API call discovers your account's data centre, and every call after that goes straight to it.

The AdobeSign constructor

new AdobeSign(
    TokenProvider $tokens,
    ?ClientInterface $httpClient = null,
    ?RequestFactoryInterface $requestFactory = null,
    ?StreamFactoryInterface $streamFactory = null,
    ?CacheInterface $cache = null,
    ?LoggerInterface $logger = null,
    ClientOptions $options = new ClientOptions(),
    ?Sleeper $sleeper = null,
    array $middleware = [],
)

Builds the HTTP pipeline (your middleware, then user agent, retry and ETag middleware) around the PSR-18 client, and wires up data-centre discovery. Resource objects are created lazily, once per client, the first time you call an accessor such as agreements().

ArgumentTypeDefaultWhat it is for
tokensAuth\TokenProviderrequiredSupplies the bearer token. IntegrationKeyTokenProvider, OAuthTokenProvider, or your own implementation.
httpClientPSR-18 ClientInterfacePsr18ClientDiscovery::find()Sends the requests. Configure timeouts and proxies here.
requestFactoryPSR-17 RequestFactoryInterfacediscoveredBuilds request objects.
streamFactoryPSR-17 StreamFactoryInterfacediscoveredBuilds JSON and multipart bodies.
cachePSR-16 CacheInterfaceCache\InMemoryCacheRemembers the account's data centre. Share one in production.
loggerPSR-3 LoggerInterfaceNullLoggerRequest, failure and retry logs. Never headers or bodies.
optionsClientOptionsnew ClientOptions()Retries, ETags, user agent, discovery, default signature level. All options.
sleeperHttp\SleeperHttp\NativeSleeperWaits between retries. Replace it in tests so nothing really sleeps.
middlewarelist<Http\Middleware\Middleware>[]Extra middleware, run outermost, around every API request.

There is no clock argument here

The client itself never looks at the time. The PSR-20 clock belongs to the OAuth classes, which need it to decide when a token expires. See PSR-20 clock below.

withIntegrationKey() and withOAuth()

The two factories build the token provider for you and forward every other argument, by name, to the constructor. Any argument from the table above can be passed to either of them:

// Your own account, with a non-expiring Integration Key
$sign = AdobeSign::withIntegrationKey($key, cache: $cache, logger: $logger);

// A user who connected their account through OAuth; tokens refresh automatically
$sign = AdobeSign::withOAuth($oauthClient, $tokenStore, $userId, cache: $cache, logger: $logger);

Pass the extra arguments by name. The factories accept them through a variadic parameter, so positional arguments after the first ones would land in the wrong place.

Building it yourself

Use the constructor directly when you need control over the token provider, for example to give OAuthTokenProvider a clock or a different refresh margin, or to plug in a TokenProvider of your own (a key fetched from a secrets manager, say):

use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Auth\OAuthTokenProvider;

$tokens = new OAuthTokenProvider(
    $oauthClient,
    $tokenStore,
    key: (string) $user->id,
    clock: $clock,
    refreshLeewaySeconds: 120,   // refresh two minutes before expiry instead of one
);

$sign = new AdobeSign($tokens, cache: $cache, logger: $logger);

A custom provider implements three methods: getAccessToken(), invalidate() (called after a 401 so a refreshable provider can fetch a new token) and cacheKey(), which names the account in the data-centre cache. Return a hash there, never the credential itself.

ClientOptions

Conceptlz\AdobeSign\ClientOptions is an immutable value object. Every property is a public readonly constructor argument with a default, so you only name the ones you change.

PropertyTypeDefaultEffect
bootstrapUrlstring'https://api.adobesign.com'Host used only for GET /baseUris. Every other call goes to the data centre it returns.
baseUriTtlint86400Seconds the discovered data centre stays in the PSR-16 cache (one day).
maxRetriesint3Retries after a 429 or 503 response. 0 turns retrying off.
retryBaseDelayfloat1.0Base of the exponential backoff, in seconds, used when Adobe doesn't say how long to wait.
maxRetryDelayfloat60.0Longest single wait, in seconds. A longer Retry-After fails fast instead of blocking.
autoEtagbooltrueRemember ETags from GETs and send them as If-Match on PUT and DELETE.
userAgentstring'conceptlz-adobe-sign-php/' . AdobeSign::VERSIONSent on every request that doesn't already carry a User-Agent.
defaultSignatureLevelEnum\SignatureLevelSignatureLevel::SESLevel applied to agreements built with AgreementInfo that don't set one.

The options are read when the client is built. To change them, build a new client; there is no setter. asUser() copies keep the options of the client they came from.

bootstrapUrl

Acrobat Sign accounts live in different data centres (na1, na4, eu1, jp1 and so on). The SDK asks a shard-agnostic host which one yours is, then talks only to that one. The default, https://api.adobesign.com, is right for commercial accounts. Change it only when Adobe gives you a different discovery host, for example on an Acrobat Sign for Government environment. OAuth clients rarely use it at all, because their tokens already name the data centre.

baseUriTtl

How long the discovered API root stays cached, in seconds. Accounts move between data centres very rarely, and when one does the API says so (403 INVALID_API_ACCESS_POINT) and the SDK looks again on its own, so a long TTL is safe. A day is a good balance; there is little to gain from going shorter. The details are on Data centres, retries & ETags.

maxRetries, retryBaseDelay and maxRetryDelay

These three tune RetryMiddleware, which retries throttled (429) and unavailable (503) responses. The wait comes from Adobe's Retry-After header when there is one, then from the "try again in N seconds" text in Adobe's error message, and otherwise from exponential backoff with full jitter: a random wait between zero and min(maxRetryDelay, retryBaseDelay × 2attempt).

You wantSet
Web requests that never hang for longmaxRetries: 1, maxRetryDelay: 5.0
Patient background jobsmaxRetries: 6, maxRetryDelay: 120.0
Your queue handles retries insteadmaxRetries: 0, then catch RateLimitException and release the job for $e->retryAfter seconds
Fast retries in a testkeep the defaults and pass a sleeper that records instead of sleeping (Testing)

When retrying stops, the last response is turned into an exception as usual: a RateLimitException for a 429, a plain ApiException for a 503. If Adobe asks for a longer wait than maxRetryDelay, the SDK doesn't wait at all; it throws straight away and puts the requested delay in $e->retryAfter.

autoEtag

On by default. The ETag middleware remembers the ETag of each successful GET and adds it as If-Match to a later PUT or DELETE on the same resource, so two processes can't overwrite each other's changes without noticing. With autoEtag: false the middleware isn't installed at all: writes go out without If-Match (Adobe treats the header as optional), so the last write wins. Turn it off only if you add your own concurrency control, for instance with a middleware that sets the header. How the memory works is described in ETags and If-Match.

userAgent

The default is conceptlz-adobe-sign-php/0.4.0 (the version comes from AdobeSign::VERSION). Adding your application's name helps when you ask Adobe support about specific requests:

new ClientOptions(userAgent: 'acme-contracts/2.4 conceptlz-adobe-sign-php/' . AdobeSign::VERSION);

A request that already has a User-Agent header, set by your own middleware for example, keeps it.

defaultSignatureLevel

The project-wide signature level. The default, SignatureLevel::SES, keeps Acrobat Sign's normal e-signature. AES or QES make every signer of every agreement sign with a certificate-based digital signature unless the agreement or the signer says otherwise:

$sign = AdobeSign::withIntegrationKey($key, options: new ClientOptions(
    defaultSignatureLevel: SignatureLevel::AES,
));

// Uses AES because the agreement sets no level of its own
$sign->agreements()->create(AgreementInfo::create('Supplier contract')->file($doc)->signers('buyer@example.com'));

// Overrides the default for this agreement only
$sign->agreements()->create(
    AgreementInfo::create('Newsletter consent')->file($doc)->signers('member@example.com')
        ->signatureLevel(SignatureLevel::SES),
);
  • The order of precedence is: the signer's own level, then the agreement's, then this default.
  • It applies only to agreements built with AgreementInfo. A raw array or a generated AgreementCreationInfo model is sent exactly as you wrote it.
  • It affects signers only. Approvers, acceptors and form fillers don't sign, so no level is applied to them.
  • QES uses the same API request as AES. Whether the result is qualified depends on the trust provider configured in your Acrobat Sign account. See Signature levels.

Choosing a PSR-18 client

The SDK depends on the PSR-18 and PSR-17 interfaces, not on a particular HTTP library. When you don't pass a client, php-http/discovery picks whichever compatible package is installed. Composer insists that one is (the package requires psr/http-client-implementation), so a project that has neither yet needs one of these:

composer require symfony/http-client nyholm/psr7   # Symfony client + Nyholm factories
composer require guzzlehttp/guzzle                 # Guzzle 7 brings its own PSR-17 factories

Discovery is fine for scripts. In an application, inject the client yourself so you control timeouts, proxies and TLS settings. The SDK sets no timeout of its own:

Symfony HttpClient
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;

// Psr18Client is also a PSR-17 request and stream factory
$http = new Psr18Client(HttpClient::create(['timeout' => 30, 'max_duration' => 300]));

$sign = AdobeSign::withIntegrationKey($key, httpClient: $http, requestFactory: $http, streamFactory: $http);
Guzzle 7
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;

$factory = new HttpFactory();
$sign = AdobeSign::withIntegrationKey(
    $key,
    httpClient: new Client(['timeout' => 30, 'connect_timeout' => 5]),
    requestFactory: $factory,
    streamFactory: $factory,
);

Don't configure the client to throw on 4xx or 5xx responses: the SDK needs to see them to retry and to map them to typed exceptions. PSR-18 clients don't throw on error statuses by default, so this only matters if you wrap your client. A network failure (DNS, TLS, timeout) surfaces as TransportException, with the client's own exception available through getPrevious().

PSR-16 cache: why it matters

Before its first real call, a client has to learn which data centre your account lives in, which costs one extra request (GET /baseUris). The answer goes into the PSR-16 cache for baseUriTtl seconds.

The default cache, Cache\InMemoryCache, lives only as long as the client object. In PHP-FPM or any other model that builds a fresh client per web request or per job, that means one extra round trip to Adobe every time. A shared cache brings it down to one lookup per account per day:

use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\Psr16Cache;

$cache = new Psr16Cache(new RedisAdapter(RedisAdapter::createConnection('redis://localhost')));
$sign = AdobeSign::withIntegrationKey($key, cache: $cache);
  • What is stored. One string per account, the API root, such as https://api.na4.adobesign.com/api/rest/v6. No tokens, no response data.
  • The cache key starts with adobe_sign.base_uri. and ends with the token provider's cacheKey(), a hash prefix such as ik_… for an Integration Key or oauth_… for an OAuth user. The credential itself never reaches the cache.
  • Several accounts can share one cache; each gets its own key.
  • OAuth clients rarely need it: the token already carries the account's api_access_point, so they skip discovery unless that address turns out to be stale.
  • Any PSR-16 implementation works: Symfony Cache, Laravel's cache repository (the Laravel bridge wires this up for you), cache/* adapters and so on.

PSR-3 logging

Give the client a logger to see what it sends. The SDK writes three kinds of entries, all with structured context and none with credentials:

LevelMessageContext keysWhen
debugAdobe Sign requestmethod, path, status, duration_msEvery API response, including error responses
warningAdobe Sign throttled request, retryingmethod, path, status, attempt, delay_secondsBefore each retry of a 429 or 503
errorAdobe Sign request failedmethod, path, errorNo response at all (network, DNS, TLS); a TransportException follows

A typical send looks like this in a log:

[debug] Adobe Sign request {"method":"POST","path":"/api/rest/v6/transientDocuments","status":201,"duration_ms":812}
[debug] Adobe Sign request {"method":"POST","path":"/api/rest/v6/agreements","status":201,"duration_ms":1290}
[warning] Adobe Sign throttled request, retrying {"method":"GET","path":"/api/rest/v6/agreements/CBJCHBCAABAA…","status":429,"attempt":1,"delay_seconds":3}
[debug] Adobe Sign request {"method":"GET","path":"/api/rest/v6/agreements/CBJCHBCAABAA…","status":200,"duration_ms":240}

What never reaches the logger

Request and response headers (so no Authorization and no x-api-user), request and response bodies (so no document content or signer data), and query strings. The path does contain resource ids such as agreement ids; treat those like any other business identifier.

The data-centre lookup and the OAuth token calls don't go through the request log; a throttled discovery call still produces the retry warning. The debug entries are a useful audit of API usage, but noisy in production: route them to a channel you can switch off.

PSR-20 clock

Time matters only for OAuth. OAuthClient turns Adobe's expires_in into an absolute AccessToken::$expiresAt, and OAuthTokenProvider refreshes the token once the clock is within refreshLeewaySeconds (default 60) of that moment. Both take an optional Psr\Clock\ClockInterface and default to Auth\SystemClock:

use Conceptlz\AdobeSign\Auth\{OAuthClient, OAuthConfig, OAuthTokenProvider};

$oauth = new OAuthClient(new OAuthConfig($clientId, $clientSecret, $redirectUri), clock: $clock);
$sign = new AdobeSign(new OAuthTokenProvider($oauth, $tokenStore, $userId, clock: $clock));

Inject a clock when your framework already has one, or in tests, where a frozen clock lets you check refresh behaviour without waiting an hour (see Testing your integration). Integration Key clients don't use a clock at all.

Sleeper and extra middleware

Http\Sleeper is a one-method interface (sleep(float $seconds): void) that the retry middleware calls between attempts. The default, Http\NativeSleeper, uses usleep(). Replace it in tests, or in an event loop where blocking the process is not acceptable.

The middleware argument takes objects implementing Http\Middleware\Middleware. They run outermost, before the user agent, retry and ETag middleware, and see every raw HTTP response, 4xx and 5xx included (errors become exceptions only after the whole stack returns). That makes them a good place for metrics or tracing:

src/AdobeSignMetrics.php
use Conceptlz\AdobeSign\Http\Middleware\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

final class AdobeSignMetrics implements Middleware
{
    public function __construct(private readonly Metrics $metrics) {}

    public function handle(RequestInterface $request, callable $next): ResponseInterface
    {
        $started = microtime(true);
        $response = $next($request);   // includes any retries made further in
        $this->metrics->timing('adobe_sign.request', microtime(true) - $started, [
            'method' => $request->getMethod(),
            'status' => $response->getStatusCode(),
        ]);

        return $response;
    }
}

$sign = AdobeSign::withIntegrationKey($key, middleware: [new AdobeSignMetrics($metrics)]);

Because your middleware sits outside the retry middleware, it sees one call per logical request, with the final response after any retries.

Recipes

A production setup

$http = new Psr18Client(HttpClient::create(['timeout' => 30]));

$sign = AdobeSign::withIntegrationKey(
    $secrets->get('adobe_sign_integration_key'),
    httpClient: $http,
    requestFactory: $http,
    streamFactory: $http,
    cache: $sharedPsr16Cache,
    logger: $logger,
    options: new ClientOptions(
        maxRetries: 4,
        maxRetryDelay: 30.0,
        userAgent: 'acme-contracts/2.4 conceptlz-adobe-sign-php/' . AdobeSign::VERSION,
    ),
);

One client per connected account

With OAuth, each user who connected an Acrobat Sign account gets their own client. Build them from the same collaborators, keyed by your user id, and keep them for the length of the request or job:

final class AdobeSignClients
{
    /** @var array<string, AdobeSign> */
    private array $clients = [];

    public function __construct(
        private readonly OAuthClient $oauth,
        private readonly TokenStore $tokens,
        private readonly CacheInterface $cache,
        private readonly LoggerInterface $logger,
    ) {}

    public function for(User $user): AdobeSign
    {
        return $this->clients[$user->id] ??= AdobeSign::withOAuth(
            $this->oauth, $this->tokens, (string) $user->id,
            cache: $this->cache,
            logger: $this->logger,
        );
    }
}

Acting as other users

asUser() returns a copy that sends x-api-user on every request. It shares the original's pipeline, cache, options and ETag memory, so creating many copies is cheap:

foreach ($teamEmails as $email) {
    foreach ($sign->asUser($email)->agreements()->all() as $summary) { /* … */ }
}

Common questions

Should I create one client and reuse it?

Yes, within a request, job or command. A client holds the ETag memory and, with the default cache, the data-centre lookup, so reusing it saves requests and keeps automatic If-Match working. Across PHP-FPM requests the object is rebuilt anyway; that is what the shared PSR-16 cache is for.

Does the SDK read environment variables?

No. The core reads nothing from the environment; you pass the key or OAuth credentials in. The examples and the Laravel bridge read ADOBE_SIGN_* variables, but that is their choice, not the SDK's.

How do I set a request timeout?

On the PSR-18 client you inject, for example HttpClient::create(['timeout' => 30]) with Symfony or new Client(['timeout' => 30]) with Guzzle. The SDK has no timeout option because PSR-18 has no standard way to pass one. See Choosing a PSR-18 client.

Can I change options after the client is built?

No. ClientOptions is read-only and the pipeline is assembled in the constructor. Build a second client if part of your code needs different retry settings; it can share the same cache and HTTP client.

Can I skip the GET /baseUris call entirely?

With OAuth it is skipped already, because the token carries the data centre. With an Integration Key, a shared cache reduces it to one call per account per baseUriTtl. There is no option to hard-code the data centre, because a wrong one would fail with INVALID_API_ACCESS_POINT anyway.