createAndSend([ * 'title' => 'Listing Agreement — 123 Main St', * 'documentBase64' => base64_encode(file_get_contents('agreement.pdf')), * 'signers' => [TalyanaSignClient::signer('Jane Seller', 'jane@example.com')], * 'fields' => [TalyanaSignClient::anchorField(0, 'signature', '/sign-here/')], * 'senderEmail' => 'agent@yourfirm.com', // attributes the envelope to that seat * 'webhookUrl' => 'https://yourportal.com/talyana-webhook.php', * 'metadata' => ['dealId' => 42], // echoed back in webhooks * ]); * // $env['id'], $env['status'] === 'sent', $env['signingUrls'][...] * * // 2. in your webhook receiver: * $event = $ts->verifyWebhook(getallheaders(), file_get_contents('php://input')); * if ($event['type'] === 'envelope.completed') { ... $event['metadata']['dealId'] ... } * * Every method throws TalyanaSignException on failure, with the server's own * error message — so wrap calls in try/catch and show $e->getMessage(). * * Full endpoint reference: INTEGRATION-API.md (ships with your Talyana Sign * install) or ask your Talyana Sign provider. * * Compatible with PHP 7.4+. */ class TalyanaSignException extends \RuntimeException { /** @var int HTTP status the server answered with (0 = transport failure). */ public $httpStatus; public function __construct(string $message, int $httpStatus = 0) { parent::__construct($message); $this->httpStatus = $httpStatus; } } class TalyanaSignClient { /** @var string */ private $baseUrl; /** @var string */ private $apiKey; /** @var string */ private $webhookSecret; /** @var int */ private $timeout; public function __construct(string $baseUrl, string $apiKey, string $webhookSecret = '', int $timeout = 60) { $this->baseUrl = rtrim($baseUrl, '/'); $this->apiKey = $apiKey; $this->webhookSecret = $webhookSecret; $this->timeout = $timeout; } /* ===================== connection ===================== */ /** * Test the connection. Returns ['ok'=>true, 'org'=>..., 'plan'=>...]. * The right call for a "Test connection" button in your admin screen. */ public function ping(): array { return $this->request('GET', '/api/ping'); } /* ===================== envelopes ===================== */ /** * Create a DRAFT envelope. $envelope uses the API's JSON shape: * title, documentBase64 (single PDF/PNG/JPEG) or documents:[{name,documentBase64},...], * signers:[{name,email},...], fields:[...], senderEmail?, metadata?, webhookUrl?, message? * Fields are either coordinate-placed {signerIndex,type,page,x,y,w,h} * or anchor-placed {signerIndex,type,anchor:'/text/'} — see the two * static helpers below. Returns the envelope summary (id, status:'draft'). */ public function createEnvelope(array $envelope): array { return $this->request('POST', '/api/envelopes', $envelope); } /** Send a draft. Signers get their email invitations at this moment. */ public function sendEnvelope(string $envelopeId): array { return $this->request('POST', '/api/envelopes/' . rawurlencode($envelopeId) . '/send'); } /** Create + send in one call. */ public function createAndSend(array $envelope): array { $draft = $this->createEnvelope($envelope); return $this->sendEnvelope($draft['id']); } /** Current envelope summary: status, signers and their states, timestamps. */ public function getEnvelope(string $envelopeId): array { return $this->request('GET', '/api/envelopes/' . rawurlencode($envelopeId)); } /** * Download the envelope's current PDF (the sealed certificate-backed copy * once completed). Returns raw PDF bytes — save with file_put_contents(). */ public function downloadDocument(string $envelopeId): string { return $this->requestRaw('GET', '/api/envelopes/' . rawurlencode($envelopeId) . '/document'); } /** Void a sent envelope / discard a draft. */ public function voidEnvelope(string $envelopeId, string $reason = ''): array { return $this->request('POST', '/api/envelopes/' . rawurlencode($envelopeId) . '/void', $reason !== '' ? ['reason' => $reason] : []); } /** * Ensure a person on YOUR system has a seat on the Talyana Sign account * (auto-provisioning): creates it with an activation email on first call, * and treats "already exists" (409) as success. Pair with the senderEmail * you pass to createEnvelope so each person sees their own envelopes. */ public function ensureSeat(string $name, string $email): bool { try { $this->request('POST', '/api/users', ['name' => $name, 'email' => $email, 'role' => 'staff', 'invite' => true]); return true; } catch (TalyanaSignException $e) { if ($e->httpStatus === 409) return true; // seat already exists return false; // non-fatal for callers: envelopes still send, just unattributed } } /* ===================== Word conversion ===================== */ /** * Convert a Word document (.docx/.doc bytes) to PDF. Returns PDF bytes. * Show the PDF to your user for review BEFORE creating an envelope from * it — signers must see exactly what gets sealed. * Requires conversion to be enabled on the Talyana Sign server (501 if not). */ public function convertWordToPdf(string $wordBytes, string $filename = 'document.docx'): string { $res = $this->request('POST', '/api/convert', [ 'documentBase64' => base64_encode($wordBytes), 'filename' => $filename, ]); $pdf = base64_decode($res['pdfBase64'] ?? '', true); if ($pdf === false || strpos($pdf, '%PDF-') !== 0) { throw new TalyanaSignException('Conversion did not return a valid PDF'); } return $pdf; } /* ===================== webhooks ===================== */ /** * Verify and decode a webhook delivery. Pass the request headers * (getallheaders() works) and the RAW body (file_get_contents('php://input') — * never json_decode first, the signature covers the exact bytes). * Returns the decoded event array; throws if the signature is missing/wrong. * * Events are terminal-only: envelope.completed, envelope.voided, * envelope.declined. Your 'metadata' from createEnvelope is echoed back. */ public function verifyWebhook(array $headers, string $rawBody): array { if ($this->webhookSecret === '') { throw new TalyanaSignException('Set the webhook secret in the TalyanaSignClient constructor to verify webhooks'); } $sig = ''; foreach ($headers as $k => $v) { $lk = strtolower((string)$k); if ($lk === 'x-talyanasign-signature' || $lk === 'x-signet-signature') { $sig = (string)$v; break; } } if ($sig === '') throw new TalyanaSignException('Webhook signature header missing'); $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $this->webhookSecret); if (!hash_equals($expected, $sig)) throw new TalyanaSignException('Webhook signature mismatch — wrong secret or tampered body'); $event = json_decode($rawBody, true); if (!is_array($event)) throw new TalyanaSignException('Webhook body is not valid JSON'); return $event; } /* ===================== field helpers ===================== */ /** A signer entry. */ public static function signer(string $name, string $email): array { return ['name' => $name, 'email' => $email]; } /** * Anchor-placed field (recommended): Talyana Sign finds $anchorText in the * final document and places the field there. Put the anchor in your * template in white or 1pt type if you don't want it visible. * $type: signature | initials | date_signed | text | checkbox ... * $all=true places one field on EVERY occurrence (e.g. initial each page). */ public static function anchorField(int $signerIndex, string $type, string $anchorText, bool $all = false, float $dx = 0, float $dy = 0): array { $f = ['signerIndex' => $signerIndex, 'type' => $type, 'anchor' => $anchorText]; if ($all) $f['anchorAll'] = true; if ($dx != 0.0 || $dy != 0.0) $f['anchorOffset'] = ['dx' => $dx, 'dy' => $dy]; return $f; } /** Coordinate-placed field. PDF points, origin bottom-left, page 0-based. */ public static function field(int $signerIndex, string $type, int $page, float $x, float $y, float $w, float $h): array { return ['signerIndex' => $signerIndex, 'type' => $type, 'page' => $page, 'x' => $x, 'y' => $y, 'w' => $w, 'h' => $h]; } /* ===================== transport ===================== */ private function request(string $method, string $path, ?array $body = null): array { $raw = $this->requestRaw($method, $path, $body); $data = json_decode($raw, true); if (!is_array($data)) throw new TalyanaSignException('Server returned a non-JSON response'); return $data; } private function requestRaw(string $method, string $path, ?array $body = null): string { $ch = curl_init($this->baseUrl . $path); $headers = ['X-API-Key: ' . $this->apiKey]; $opts = [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP, ]; if ($body !== null) { $headers[] = 'Content-Type: application/json'; $opts[CURLOPT_POSTFIELDS] = json_encode($body, JSON_UNESCAPED_SLASHES); } $opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); $res = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_error($ch); curl_close($ch); if ($res === false) { throw new TalyanaSignException('Could not reach Talyana Sign: ' . $err); } if ($status >= 400) { $decoded = json_decode((string)$res, true); $msg = is_array($decoded) && !empty($decoded['error']) ? $decoded['error'] : 'HTTP ' . $status; throw new TalyanaSignException($msg, $status); } return (string)$res; } }