signingstudio / signingstudio-php
Official PHP SDK for the Signing Studio e-signature API.
Requires
- php: >=8.1
- ext-hash: *
- ext-json: *
- guzzlehttp/guzzle: ^7.5
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.40
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.5
This package is not auto-updated.
Last update: 2026-07-23 14:15:34 UTC
README
Official PHP client for the Signing Studio e-signature API. Covers every public v1 endpoint — send documents from templates, poll signing progress, manage templates and their fields, and verify webhook deliveries.
Install
composer require signingstudio/signingstudio-php
Requires PHP 8.1+ and the json + hash extensions (both ship by default).
Quick start
use SigningStudio\Client; $client = new Client('sk_live_your_api_key'); $doc = $client->documents()->send([ 'template_id' => '11111111-2222-3333-4444-555555555555', 'title' => 'MSA — Acme', 'recipients' => [ ['name' => 'Alex Doe', 'email' => 'alex@acme.com'], ], ]); echo "Sent {$doc['id']} ({$doc['status']})\n";
Get your API key from Signing Studio → Settings → API Keys. The plaintext key is shown once at creation — store it in a secrets manager, never in source control.
Configuration
The one-arg constructor is enough for most callers. Pass a Config when you need to override the base URL (staging), retry ceiling, or timeouts:
use SigningStudio\Client; use SigningStudio\Config; $client = new Client(new Config( apiKey: 'sk_live_...', baseUrl: 'https://api.signingstudio.com', // default maxRetries: 3, // 429 + 5xx + network errors connectTimeout: 10, // seconds requestTimeout: 120, // seconds userAgent: 'my-app/1.4', ));
The retry policy is intentionally conservative:
- 429 with
Retry-After ≤ 60s→ sleep and retry, up tomaxRetries. - 429 with
Retry-After > 60s(typically a daily quota) → surfaceRateLimitExceptionimmediately. - 5xx → exponential backoff (500ms · 1s · 2s · 4s · …) with jitter, up to
maxRetries. - Network errors (connect fail, read timeout) → same backoff as 5xx.
Documents
// List $list = $client->documents()->list([ 'status' => 'sent', 'view' => 'active', // 'archived' | 'deleted' | 'all' 'limit' => 50, 'offset' => 0, ]); // Send from a template $doc = $client->documents()->send([ 'template_id' => $templateId, 'title' => 'MSA — Acme', 'subject' => 'Please sign', // optional email subject 'message' => 'Signing at your convenience', 'expires_at' => '2026-08-01T00:00:00Z', // optional 'recipients' => [ ['name' => 'Alex', 'email' => 'alex@acme.com', 'signing_order' => 0], ['name' => 'Bo', 'email' => 'bo@acme.com', 'signing_order' => 1], ], 'prefill_values' => [ // optional ['field_name' => 'company', 'value' => 'Acme Inc.'], ], ]); // Read $doc = $client->documents()->get($id); $progress = $client->documents()->progress($id); // cheap; ideal for polling $activity = $client->documents()->activity($id); // full audit log // Actions $client->documents()->cancel($id); $client->documents()->archive($id); $client->documents()->unarchive($id); $client->documents()->restore($id); $client->documents()->delete($id); // soft $client->documents()->delete($id, hard: true); // hard purge (must be cancelled/completed/declined/expired) // Reminder — mints a fresh signing URL for a specific recipient. $out = $client->documents()->remind($documentId, $recipientId); $fresh = $out['signing_url']; // Download $url = $client->documents()->downloadUrl($id)['url']; // presigned; ~1h TTL $bytes = $client->documents()->downloadPdf($id); // raw bytes
Send-payload rules the server enforces
template_idis required; ad-hoc PDF sends without a template are not exposed on v1.recipientsmust have at least one entry. Sequential signing runs insigning_order.prefill_values[]must have eitherfield_id(uuid) or a non-emptyfield_nameslug plus avalue.- Signature / initials fields cannot be prefilled — 422 if you try.
- Any template field that is both
required: trueandreadonly: trueMUST be prefilled — 422 with a list of missing labels. - Sends count against the account's monthly
documents_per_monthquota — 429 when tripped.
Templates
// List active templates $templates = $client->templates()->list(); // Create from a PDF (path, resource, or raw bytes all accepted) $template = $client->templates()->create( __DIR__ . '/msa.pdf', [ 'name' => 'MSA v2', 'signer_count' => 1, 'delivery_methods' => ['email'], 'signers' => [ ['role' => 'Customer', 'delivery' => ['email']], ], ], ); // Get / partial update / soft-delete $template = $client->templates()->get($id); $client->templates()->update($id, ['name' => 'MSA v3']); $client->templates()->delete($id); // PDF versioning — replace and history $client->templates()->replacePdf($id, __DIR__ . '/msa-updated.pdf'); $versions = $client->templates()->history($id); $oldPdfUrl = $client->templates()->historyPdfUrl($id, $versions[0]['id'])['url']; // Fields — full replace $client->templates()->setFields($id, [ [ 'field_type' => 'signature', 'page' => 1, 'x' => 60, 'y' => 82, 'width' => 30, 'height' => 6, 'signer_index' => 0, 'required' => true, ], [ 'field_type' => 'text', 'page' => 1, 'x' => 10, 'y' => 20, 'width' => 30, 'height' => 4, 'signer_index' => 0, 'name' => 'company', 'label' => 'Company name', 'required' => true, ], ]); // Duplicate + archive $copy = $client->templates()->duplicate($id); $client->templates()->archive($id);
PDF upload constraints
- Max 50 MB per file.
application/pdfonly — other content types 400.- The multipart form field name must be
file(the SDK sets this for you). - Passing a string that looks like a filesystem path opens it with
fopen; anything else is treated as raw PDF bytes.
Webhooks
Verify incoming deliveries against your webhook's secret before trusting the payload:
use SigningStudio\Client; $verifier = Client::webhookVerifier('whs_your_secret'); $raw = file_get_contents('php://input') ?: ''; $sig = $_SERVER['HTTP_X_DDS_SIGNATURE'] ?? ''; if (!$verifier->isValid($raw, $sig)) { http_response_code(401); exit; } $payload = json_decode($raw, true); // $payload['event'] is one of: // document.sent | document.viewed | document.signed | document.declined | document.completed
Payload shape
{
"event": "document.signed",
"data": {
"document": {
"id": "uuid",
"title": "string",
"status": "sent|viewed|completed|declined|cancelled|expired",
"template_id": "uuid|null",
"sent_at": "…|null",
"completed_at": "…|null"
},
"download_url": "https://…|null",
"download_expires_in_seconds": 3600,
"recipients": [ /* …with viewed_at/signed_at/declined_at */ ],
// Event-specific:
// document.viewed / .signed → { "recipient": {…}, "ip": "…"|null }
// document.declined → { "recipient": {…}, "reason": "…"|null, "ip": "…"|null }
// document.completed → { "completed_by": {…} }
},
"timestamp": "2026-07-20T14:32:11Z"
}
Always sign the RAW body, not a parsed-and-re-encoded body. Any whitespace shift breaks the HMAC.
Errors
use SigningStudio\Exception\{ SigningStudioException, ApiException, AuthenticationException, NotFoundException, ValidationException, RateLimitException, }; try { $client->documents()->send([...]); } catch (ValidationException $e) { foreach ($e->getErrors() as $field => $messages) { /* … */ } } catch (RateLimitException $e) { // $e->getWindow() === 'minute' | 'day' | null (quota) // $e->getRetryAfter() === int|null } catch (AuthenticationException $e) { // Refresh the API key. } catch (NotFoundException $e) { // The resource does not exist (or belongs to another tenant). } catch (ApiException $e) { // Everything else that came back with a non-2xx HTTP status. error_log("request={$e->getRequestId()} status={$e->getStatusCode()}"); }
All SDK-thrown exceptions inherit from SigningStudioException.
Rate limits
Every response carries:
X-RateLimit-Limit-Minute,X-RateLimit-Remaining-MinuteX-RateLimit-Limit-Day,X-RateLimit-Remaining-Day
Response::getRateLimitRemainingMinute() / getRateLimitRemainingDay() expose the remaining counts:
$response = $client->documents()->list([...]); // Response object isn't returned by the resource methods directly today — // they return the parsed data array. The rate-limit headers are still // received by the transport; if you need them, drop down to the HttpClient.
Platform defaults are 120 requests/minute and 20,000 requests/day per API key. Per-account and per-key overrides can raise or lower these.
Testing
composer install composer test # phpunit composer analyse # phpstan level 7 composer cs-check # php-cs-fixer dry-run
CI runs against PHP 8.1, 8.2, 8.3, 8.4 on every push/PR.
Versioning
Semantic versioning. Breaking changes bump the major; new endpoints or fields bump the minor; bug fixes bump the patch. CHANGELOG.md records every release.
License
MIT. See LICENSE.