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:
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().
| Argument | Type | Default | What it is for |
|---|---|---|---|
tokens | Auth\TokenProvider | required | Supplies the bearer token. IntegrationKeyTokenProvider, OAuthTokenProvider, or your own implementation. |
httpClient | PSR-18 ClientInterface | Psr18ClientDiscovery::find() | Sends the requests. Configure timeouts and proxies here. |
requestFactory | PSR-17 RequestFactoryInterface | discovered | Builds request objects. |
streamFactory | PSR-17 StreamFactoryInterface | discovered | Builds JSON and multipart bodies. |
cache | PSR-16 CacheInterface | Cache\InMemoryCache | Remembers the account's data centre. Share one in production. |
logger | PSR-3 LoggerInterface | NullLogger | Request, failure and retry logs. Never headers or bodies. |
options | ClientOptions | new ClientOptions() | Retries, ETags, user agent, discovery, default signature level. All options. |
sleeper | Http\Sleeper | Http\NativeSleeper | Waits between retries. Replace it in tests so nothing really sleeps. |
middleware | list<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.
| Property | Type | Default | Effect |
|---|---|---|---|
bootstrapUrl | string | 'https://api.adobesign.com' | Host used only for GET /baseUris. Every other call goes to the data centre it returns. |
baseUriTtl | int | 86400 | Seconds the discovered data centre stays in the PSR-16 cache (one day). |
maxRetries | int | 3 | Retries after a 429 or 503 response. 0 turns retrying off. |
retryBaseDelay | float | 1.0 | Base of the exponential backoff, in seconds, used when Adobe doesn't say how long to wait. |
maxRetryDelay | float | 60.0 | Longest single wait, in seconds. A longer Retry-After fails fast instead of blocking. |
autoEtag | bool | true | Remember ETags from GETs and send them as If-Match on PUT and DELETE. |
userAgent | string | 'conceptlz-adobe-sign-php/' . AdobeSign::VERSION | Sent on every request that doesn't already carry a User-Agent. |
defaultSignatureLevel | Enum\SignatureLevel | SignatureLevel::SES | Level 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 want | Set |
|---|---|
| Web requests that never hang for long | maxRetries: 1, maxRetryDelay: 5.0 |
| Patient background jobs | maxRetries: 6, maxRetryDelay: 120.0 |
| Your queue handles retries instead | maxRetries: 0, then catch RateLimitException and release the job for $e->retryAfter seconds |
| Fast retries in a test | keep 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 generatedAgreementCreationInfomodel 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:
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);
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'scacheKey(), a hash prefix such asik_…for an Integration Key oroauth_…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:
| Level | Message | Context keys | When |
|---|---|---|---|
debug | Adobe Sign request | method, path, status, duration_ms | Every API response, including error responses |
warning | Adobe Sign throttled request, retrying | method, path, status, attempt, delay_seconds | Before each retry of a 429 or 503 |
error | Adobe Sign request failed | method, path, error | No 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:
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.