Send documents for e-signature from Convex with reactive envelope status, verified webhooks, completion callbacks, and signed PDF retrieval via @zsign/convex.
npm install @zsign/convexThe zsign component integrates the zSign e-signature API into a Convex backend, handling envelope creation, webhook ingestion, and artifact retrieval. It provides reactive status queries so UI components can subscribe to envelope progress without polling, and runs completion callbacks via internal mutations when documents are signed. Signed PDFs and completion certificates are accessible through component actions once an envelope reaches terminal state.
The @zsign/convex component exposes a zsign.send() action that accepts a file, filename, and recipients array and returns a documentId, sessionId, and signing URLs. Pass an operationId as an idempotency key so retries short-circuit to the stored result instead of creating a second envelope. Recipients are configured with name, email, role, and signingOrder.
The @zsign/convex component ships a verifyWebhookRequest helper that validates the X-Webhook-Signature header using HMAC-SHA256 over a timestamp and raw body with a 300-second tolerance window. Wire it in convex/http.ts before calling the internal applyWebhookEvent mutation so invalid or replayed webhook IDs never reach the database. Both current and previous webhook secrets are accepted in parallel to support secret rotation.
The @zsign/convex component persists an onCompleted callback record when a document.completed webhook event arrives. Call zsign.onCompleted() from a Convex cron action, passing your own internalMutation handler, to drain pending callbacks. Each callback is deduped by envelope and kind, failures are recorded with lastError, and the next drain retries failed handlers automatically.
After a zSign envelope reaches completed status, the @zsign/convex component provides the completedDocumentId via zsign.status(). Pass that ID to zsign.getSignedPdf() to retrieve the signed PDF as an ArrayBuffer, or call zsign.getCertificate() with the original documentId to retrieve the completion certificate.
The @zsign/convex component requires ZSIGN_API_KEY, which is a zs_live_ or zs_test_ organization key. ZSIGN_API_BASE_URL is optional and defaults to https://zsign.io. ZSIGN_WEBHOOK_SECRET and ZSIGN_WEBHOOK_SECRET_PREVIOUS are optional but required if you want webhook signature verification and support for secret rotation. All four are declared in convex.config.ts under app.use(zsign, { env: { ... } }).
The @zsign/convex component uses the operationId you provide as an idempotency key for send operations. If you call zsign.send() twice with the same operationId, the second call returns the stored result with replayed: true and does not create a second envelope. Incoming webhooks are deduplicated by X-Webhook-Id, and unknown envelope sessions are stored as orphaned events and re-attached when the envelope record appears.
Because component HTTP routes did not mount on self-hosted Convex backends, @zsign/convex requires you to register the route yourself in convex/http.ts. Add an http.route for POST /zsign/webhook, call verifyWebhookRequest from @zsign/convex to validate the signature, then run the components.zsign.lib.applyWebhookEvent mutation if verification succeeds. Return 503 on receipt failure so zSign retries delivery.
The @zsign/convex component guards state transitions with a generation counter and status rank so older or lower-priority events cannot overwrite newer canonical state. When a conflict is detected, the component schedules a reconcile.refreshEnvelope action that fetches GET /api/v1/documents/{id} from the zSign API and applies authoritative state only if the generation still matches. Backoff is bounded and terminal envelopes are never refreshed.
Completion callbacks in @zsign/convex are at-least-once. Each callback is deduplicated by envelope and kind and persisted with a status of succeeded or failed. A handler that throws is marked failed with a lastError field and will be retried the next time you drain with zsign.onCompleted(). This means your internalMutation handler should be idempotent.