Reference
Data centres, retries & ETags
Every call you make goes through the same small HTTP pipeline. It finds the data centre your account lives in, waits and retries when Adobe throttles you, guards updates with ETags, and keeps large files out of memory. This page explains each step, so you know what the SDK does for you and where its limits are.
The request pipeline
A resource method such as agreements()->get($id) hands the request to an internal
Transport. It adds the bearer token and any x-api-user header, points the
URL at your data centre, and sends it through the middleware stack to your PSR-18 client:
middleware argument, sees one call per request, after all retries.When the response comes back, Transport decides what happens next:
- 2xx or 304: the response is returned to the resource method, which decodes it.
- 401: the token provider is told to
invalidate(). If it then hands out a different token (an OAuth provider refreshes), the request is sent once more. An Integration Key can't be refreshed, so it fails straight away. - 403 with
INVALID_API_ACCESS_POINT: the cached data centre is dropped, rediscovered, and the request is sent once more. - Anything else: the error mapper turns it into a typed exception. See Errors & exceptions.
Transport, Pipeline and the three built-in middleware classes are marked
@internal: you configure them through ClientOptions and the constructor
rather than building them yourself. The Middleware interface and the
Sleeper interface are public.
Data-centre discovery
Acrobat Sign stores each account on one data centre, or shard: na1, na4,
eu1, jp1 and others. API calls must go to that shard's host;
a request sent elsewhere is refused with 403 INVALID_API_ACCESS_POINT. The SDK finds the
right host for you, once, and remembers it.
How the host is found
- Ask the token
An OAuth token carries the account's
api_access_pointfrom the OAuth redirect. If there is one, it is used and nothing else happens. - Ask the cache
Otherwise the PSR-16 cache is checked for a root discovered earlier for this account.
- Ask Adobe
Otherwise the SDK calls
GET /baseUrison the bootstrap host, takesapiAccessPointfrom the answer, appendsapi/rest/v6and stores the result forbaseUriTtlseconds.
(automatic, before the first call)GET https://api.adobesign.com/api/rest/v6/baseUris
Authenticated with the same token as every other call. A typical answer:
{
"apiAccessPoint": "https://api.na4.adobesign.com/",
"webAccessPoint": "https://secure.na4.adobesign.com/"
}
Every later request then goes to https://api.na4.adobesign.com/api/rest/v6/…. The
access point must be HTTPS; anything else is refused with an ApiException, so a
tampered or misconfigured response can never send your token over plain HTTP. A reply without
apiAccessPoint is an ApiException too.
Because discovery is authenticated, it is also the first place a bad Integration Key shows up: a fresh
client with a wrong key fails on GET /baseUris with an AuthenticationException.
Cache key and lifetime
| Setting | Value |
|---|---|
| Key | adobe_sign.base_uri. followed by the token provider's cacheKey() (ik_ or oauth_ plus a 32-character hash prefix) |
| Value | the API root, e.g. https://api.na4.adobesign.com/api/rest/v6 |
| Lifetime | ClientOptions::$baseUriTtl, default 86400 seconds |
| Default store | Cache\InMemoryCache, one per client (shared with its asUser() copies) |
With the default store, every new client object discovers again. In a web application that means one extra request per page view, which is why Configuration recommends a shared cache.
Rediscovery after INVALID_API_ACCESS_POINT
If an account is moved to another shard, or a cached entry is wrong for any reason, Adobe answers
403 INVALID_API_ACCESS_POINT. The SDK then deletes the cache entry, stops trusting the
token's own access point for that account, calls GET /baseUris again and resends the
request, once. If the retried request fails the same way, the error is thrown as an
ApiException with errorCode INVALID_API_ACCESS_POINT.
OAuth tokens and api_access_point
During the OAuth redirect Adobe adds api_access_point to your callback URL, and the SDK
stores it on the AccessToken. Because it arrives in a query string anyone could forge,
OAuthConfig checks it before use: it must be HTTPS on one of the
allowedDomains (by default adobesign.com, echosign.com and
adobesigngov.us). Otherwise the SDK throws InvalidArgumentException rather
than send your client secret to an unknown host. Tokens refresh and revoke against the same host.
Retries and rate limits
Acrobat Sign limits how many calls an account can make in a period, and answers
429 Too Many Requests (error code THROTTLING_TOO_MANY_REQUESTS) when you go
over. It also answers 503 during short outages. RetryMiddleware waits and
resends both, so a short burst doesn't break your code.
What is retried
- Statuses 429 and 503, on any method. Other errors are never retried by the middleware, because repeating a 400 or 404 can't succeed.
- Only requests whose body can be replayed. JSON bodies and the multipart upload body are rewindable streams, so they qualify. A one-shot body that can't be rewound is not retried.
- Network failures are not retried. A timeout or connection error never produced a
response, so it goes straight to you as
TransportException. Retry at the job level if you need to; a POST that timed out may still have been processed by Adobe.
How long it waits
The delay before each retry comes from the first of these that gives an answer:
- The
Retry-Afterheader, in seconds (Retry-After: 3) or as an HTTP date. - The text "try again in N seconds" in Adobe's error message, which Adobe often sends instead of the header.
- Exponential backoff with full jitter: a random delay between 0 and
min(maxRetryDelay, retryBaseDelay × 2attempt), whereattemptcounts from 0.
| Retry | Backoff ceiling with defaults | With retryBaseDelay: 2.0 |
|---|---|---|
| 1st | up to 1 s | up to 2 s |
| 2nd | up to 2 s | up to 4 s |
| 3rd | up to 4 s | up to 8 s |
| 6th | up to 32 s | up to 60 s (capped by maxRetryDelay) |
The jitter spreads retries from many workers over time, so they don't all hit Adobe again in the same
second. The same rules give RateLimitException::$retryAfter its value.
When retrying stops
- Retries used up. After
maxRetriesretries (default 3, so four attempts in all) the last response is returned toTransportand becomes an exception: aRateLimitExceptionfor a 429, anApiExceptionwith status 503 for a 503. - Adobe asks for too long. If
Retry-After(or the message) asks for more thanmaxRetryDelayseconds, the middleware doesn't wait at all. You get aRateLimitExceptionat once, with the requested delay in$retryAfter, instead of a web request that blocks for minutes. maxRetries: 0turns the middleware into a pass-through.
use Conceptlz\AdobeSign\Exception\RateLimitException;
try {
$sign->agreements()->sendReminder($agreementId);
} catch (RateLimitException $e) {
// Retries are exhausted, or Adobe asked for a long pause. Try again later, not now.
$queue->later($e->retryAfter ?? 60, new SendReminder($agreementId));
}
ETags and If-Match
Adobe gives each version of an agreement, webhook or other resource an ETag. If you send that ETag back
in If-Match when you change the resource, Adobe applies the change only if nobody changed
it in between, and answers 412 Precondition Failed otherwise. That is optimistic
concurrency: two workers can't silently overwrite each other. EtagMiddleware does the
bookkeeping so your code doesn't have to.
What it remembers
- After each successful GET that returns an
ETagheader, it stores that ETag under the request path, for example/api/rest/v6/agreements/CBJ…. - Before storing, it forgets everything remembered below that path. A fresh read of an
agreement supersedes an older ETag kept for, say, its
/statesub-path. - It keeps the 500 most recently stored paths and drops the oldest beyond that.
- The memory lives as long as the client and is shared with its
asUser()copies, which use the same pipeline.
If-Match on writes
A PUT or DELETE that doesn't already carry If-Match gets the ETag of the
same path or, failing that, of its nearest parent down to the item level. So
PUT /agreements/{id}/state uses the ETag from GET /agreements/{id}, which is
what Adobe expects. A collection's ETag (GET /agreements) is never lent to an item.
POST requests never get If-Match. An If-Match you set yourself, in a
middleware for example, always wins.
After a successful write
A write changes the resource, so the ETags held for it are stale. The middleware forgets the resource
whose ETag it used and everything below it. It then stores the ETag the write returned
only if the write was to that resource itself (a root write such as
PUT /agreements/{id}). A sub-resource write such as PUT /agreements/{id}/state
may return an ETag too, but live testing showed that the next state change must use the agreement's
current ETag, so that one is not kept.
| Call | Request | Remembered afterwards |
|---|---|---|
get($id) | GET /agreements/{id} | /agreements/{id} → "e1" |
update($id, $info) | PUT /agreements/{id}, If-Match: "e1" | /agreements/{id} → "e2", if the PUT returned an ETag (a root write keeps it) |
updateState($id, 'IN_PROCESS') | PUT /agreements/{id}/state, If-Match: "e2" | nothing for this agreement |
cancel($id) | PUT /agreements/{id}/state, no If-Match | sent unguarded, because nothing is remembered |
get($id) then cancel($id) | fresh ETag "e4", then If-Match: "e4" | the safe pattern |
The habit to build: read, then write. Call get() just before an update or
state change and the write is guarded with the newest ETag.
When the ETag is stale: 412
If someone else changed the resource since your get(), Adobe answers
412 RESOURCE_MODIFIED and you get a PreconditionFailedException. Fetch again,
reapply your change, and send it once more:
use Conceptlz\AdobeSign\Exception\PreconditionFailedException;
for ($attempt = 1; ; $attempt++) {
$details = $sign->agreements()->get($agreementId)->details(); // fresh ETag
$details->setMessage('Updated instructions');
try {
$sign->agreements()->update($agreementId, $details); // If-Match sent automatically
break;
} catch (PreconditionFailedException $e) {
if ($attempt === 3) {
throw $e;
}
sleep(2);
}
}
412 right after sending is normal
For a few seconds after an agreement is sent, Adobe keeps updating it in the background while it
delivers emails. An ETag read in that window can already be stale. A short pause and a fresh
get() fixes it; the SDK's own live tests cancel this way.
Turning it off
new ClientOptions(autoEtag: false) leaves the middleware out of the pipeline. Writes then go
without If-Match, which Adobe accepts, and the last write wins. Agreement::$etag
still holds the ETag from get() if you want to send it yourself through a middleware.
The User-Agent header
UserAgentMiddleware adds ClientOptions::$userAgent, by default
conceptlz-adobe-sign-php/0.4.0, to every request that doesn't already have a
User-Agent. It identifies the SDK and its version in Adobe's logs, which helps support find
your requests. Prefix your own application name if you like; see
Configuration.
Cursor pagination
List endpoints return a page of results and a nextCursor. Every list method in the SDK
returns a PHP \Generator that walks those pages lazily:
foreach ($sign->agreements()->all(pageSize: 50) as $summary) {
if ($summary->status === 'WAITING_FOR_MY_SIGNATURE') {
echo "{$summary->name}\n";
}
}
- Nothing is fetched until you iterate. Calling
all()sends no request; the firstforeachstep does. Errors therefore appear inside the loop, not on the line that calledall(). Put thetryaround the loop. - The next page is fetched only when you reach it.
breakearly and the remaining pages are never requested. - It stops when a page has no
nextCursor, an empty one, or one it has already seen, so a misbehaving response can't loop forever. pageSizesets the items per request (default 100). Larger pages mean fewer calls against your rate limit.- A generator runs once. Call the method again to start over, or use
iterator_to_array()if you really need everything in memory.
| Method | Endpoint | Yields |
|---|---|---|
agreements()->all() | GET /agreements | Model\AgreementSummary |
libraryDocuments()->all() | GET /libraryDocuments | generated model |
widgets()->all() | GET /widgets | generated model |
widgets()->agreements($widgetId) | GET /widgets/{widgetId}/agreements | generated model |
megaSigns()->all() | GET /megaSigns | generated model |
megaSigns()->agreements($megaSignId) | GET /megaSigns/{megaSignId}/agreements | generated model |
users()->all() | GET /users | generated model |
users()->events($userId) | GET /users/{userId}/events | generated model |
webhooks()->all() | GET /webhooks | generated model |
Streaming uploads and downloads
Uploads
transientDocuments()->upload() accepts a path, an SplFileInfo, an open
stream resource or a PSR-7 stream. It builds the multipart body by copying the file in 64 KB chunks
into php://temp, which PHP keeps in memory up to 2 MB and moves to a temporary file beyond
that. A large PDF therefore never sits in memory as one string. Because that body is rewindable, an
upload that hits a 429 is retried with the complete file.
Downloads
Binary endpoints (combinedDocument(), document(), auditTrail(),
formData() and their equivalents on other resources) return the response body as a PSR-7
StreamInterface. The SDK never converts it to a string. Copy it in chunks, or hand the
underlying resource to whatever stores it:
$pdf = $sign->agreements()->combinedDocument($agreementId, attachAuditReport: true);
$out = fopen("/var/app/agreements/$agreementId.pdf", 'wb');
while (!$pdf->eof()) {
fwrite($out, $pdf->read(65536));
}
fclose($out);
// or: $filesystem->writeStream("agreements/$agreementId.pdf", $pdf->detach());
Whether the bytes arrive from the network as you read, or are buffered by the HTTP client first, depends
on the PSR-18 client you use. A stream can be read once; to read it again, call
rewind() if isSeekable() is true, or download again.
Common questions
How do I find out which data centre my account is on?
Look at the address bar when you sign in to Acrobat Sign: secure.na4.adobesign.com
means na4. In code, a small middleware can read $request->getUri()->getHost()
(the log entries carry only the path). You never need to configure it; the SDK discovers it
(How the host is found).
Is it safe that the SDK retries POST requests?
Yes, for the statuses it retries. A 429 or 503 means Adobe didn't process the request, so sending it again can't create a duplicate. Network errors, where that is less certain, are never retried automatically.
Why did an update go out without If-Match?
Because the client hadn't read that resource since its last write, or this is a new client object.
The memory is per client and is cleared by every successful write. Call get() right
before the write (After a successful write).
How do I count agreements without loading them all?
The list endpoints don't return a total, so counting means walking every page. Use the largest
sensible pageSize and iterator_count(), and cache the result. For a
specific agreement, store its id or an externalId and filter with
all(externalId: …).
What are Adobe's rate limits?
Adobe sets them per account and plan and doesn't publish one fixed number, so the SDK doesn't assume
any. It reacts to what Adobe says: the Retry-After header or the wait time in the
error message. If you hit 429s often, batch work into background jobs and use webhooks instead of
polling.