Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

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:

Your code agreements()->get() Transport adds the bearer token resolves the data centre adds x-api-user 401 / 403: retries once maps errors to exceptions your middleware (optional) UserAgentMiddleware RetryMiddleware EtagMiddleware PSR-18 client metrics, tracing, logging sets User-Agent if missing 429 / 503: wait, then resend If-Match on PUT and DELETE Acrobat Sign api.na4.adobesign.com the response travels back up the same stack
The first middleware in the list is the outermost. Your own middleware, passed through the constructor's 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

  1. Ask the token

    An OAuth token carries the account's api_access_point from the OAuth redirect. If there is one, it is used and nothing else happens.

  2. Ask the cache

    Otherwise the PSR-16 cache is checked for a root discovered earlier for this account.

  3. Ask Adobe

    Otherwise the SDK calls GET /baseUris on the bootstrap host, takes apiAccessPoint from the answer, appends api/rest/v6 and stores the result for baseUriTtl seconds.

(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

SettingValue
Keyadobe_sign.base_uri. followed by the token provider's cacheKey() (ik_ or oauth_ plus a 32-character hash prefix)
Valuethe API root, e.g. https://api.na4.adobesign.com/api/rest/v6
LifetimeClientOptions::$baseUriTtl, default 86400 seconds
Default storeCache\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.

Try 1 429 Retry-After: 3 sleep 3 s (from the header) Try 2 429 no hint sleep 0–2 s (backoff, jitter) Try 3 200 returned 0 s ≈ 3 s ≈ 3–5 s time With maxRetries = 3, a fourth 429 in a row would be thrown as RateLimitException.
One logical request, three HTTP attempts. Your code sees only the final 200; the logger sees a warning before each retry.

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:

  1. The Retry-After header, in seconds (Retry-After: 3) or as an HTTP date.
  2. The text "try again in N seconds" in Adobe's error message, which Adobe often sends instead of the header.
  3. Exponential backoff with full jitter: a random delay between 0 and min(maxRetryDelay, retryBaseDelay × 2attempt), where attempt counts from 0.
RetryBackoff ceiling with defaultsWith retryBaseDelay: 2.0
1stup to 1 sup to 2 s
2ndup to 2 sup to 4 s
3rdup to 4 sup to 8 s
6thup to 32 sup 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 maxRetries retries (default 3, so four attempts in all) the last response is returned to Transport and becomes an exception: a RateLimitException for a 429, an ApiException with status 503 for a 503.
  • Adobe asks for too long. If Retry-After (or the message) asks for more than maxRetryDelay seconds, the middleware doesn't wait at all. You get a RateLimitException at once, with the requested delay in $retryAfter, instead of a web request that blocks for minutes.
  • maxRetries: 0 turns 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 ETag header, 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 /state sub-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.

CallRequestRemembered 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-Matchsent 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 first foreach step does. Errors therefore appear inside the loop, not on the line that called all(). Put the try around the loop.
  • The next page is fetched only when you reach it. break early 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.
  • pageSize sets 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.
MethodEndpointYields
agreements()->all()GET /agreementsModel\AgreementSummary
libraryDocuments()->all()GET /libraryDocumentsgenerated model
widgets()->all()GET /widgetsgenerated model
widgets()->agreements($widgetId)GET /widgets/{widgetId}/agreementsgenerated model
megaSigns()->all()GET /megaSignsgenerated model
megaSigns()->agreements($megaSignId)GET /megaSigns/{megaSignId}/agreementsgenerated model
users()->all()GET /usersgenerated model
users()->events($userId)GET /users/{userId}/eventsgenerated model
webhooks()->all()GET /webhooksgenerated 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.