Zsign
zSign e-signature component for Bijection — send documents, watch signing status reactively, and fetch signed artifacts.
git clone https://github.com/bijectionhq/zsign.git
cd zsign
git checkout 3c612ca5f759865c954685033a494739c380659eBijection repository access is required. Request access.
Description
zSign e-signature component for Bijection — send documents, watch signing status reactively, and fetch signed artifacts.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: bijectionhq/zsign. Based on azlabsio/zsign-plugin. License: MIT. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
zSign e-signature component for Convex — send documents, watch signing status reactively, and fetch signed artifacts.
Explore the demo ↗Upstream overview
From the upstream package documentation.
The 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.
Use cases
From the upstream package documentation.
- Adding e-signature flows to a Convex app where you need to track envelope status reactively in the UI without building a separate polling layer
- Running server-side logic (advancing a workflow, updating records) exactly when a document is signed, using at-least-once completion callbacks backed by persistent state
- Implementing idempotent document sends so retries and replays do not create duplicate envelopes or double-charge signing credits
- Ingesting and verifying zSign webhooks with HMAC-SHA256 signature checks, deduplication, and orphaned-event handling before touching your own data
- Retrieving signed PDFs and completion certificates as ArrayBuffers from within Convex actions after an envelope completes
How it works
From the upstream package documentation.
Configuration is declared in convex/convex.config.ts using app.use(zsign, { env: { ZSIGN_API_KEY, ... } }). The component exposes a Zsign client class instantiated with components.zsign. The zsign.send(ctx, { file, recipients, operationId, ... }) action uploads a document to the zSign API using a manually built multipart request (required because Convex's runtime FormData drops Blob MIME types) and stores the resulting envelope keyed by operationId for idempotent replays.
Webhooks are wired manually in convex/http.ts using the exported verifyWebhookRequest helper, which validates the X-Webhook-Signature HMAC-SHA256 header with 300-second tolerance and supports secret rotation via a previous secret. Verified events are applied with components.zsign.lib.applyWebhookEvent. Unknown envelope sessions are stored as orphaned rows and re-attached when the matching envelope arrives. Out-of-order events trigger a reconciliation flow that fetches canonical state from GET /api/v1/documents/{id} with a generation guard to prevent stale applies.
Status is exposed through two reactive queries: zsign.status(ctx, operationId) returns envelope and recipient detail, and zsign.list(ctx, { activeOnly: true }) returns non-terminal envelopes. These can be subscribed to from the UI or read inside other Convex functions. Completion callbacks are internal mutations registered via zsign.onCompleted(ctx, internal.myFunctions.onEnvelopeCompleted), called from a drain action such as a cron. Each callback is deduped and persisted; failures are recorded with lastError and retried on the next drain, giving at-least-once delivery semantics.
Upstream package FAQ
What environment variables does @zsign/convex require?
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: { ... } }).
How does @zsign/convex handle duplicate send calls or webhook replays?
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.
How do I wire the zSign webhook route in Convex?
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.
How does @zsign/convex keep envelope state consistent when webhooks arrive out of order?
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.
Are completion callbacks in @zsign/convex exactly-once or at-least-once?
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.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Zsign with Bijection.
Read: https://bijection.com/components/zsign/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from azlabsio/zsign-plugin, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
@zsign/convex
zSign e-signature component for Convex. An app installs this component to send a document for signature, observe envelope progress reactively, run completion callbacks, and retrieve the signed PDF + completion certificate when done.
Published on npm as @zsign/convex.
example/ is the minimal authenticated demo app it was proven with.
Agent-facing install/use guide: SKILL.md.
Install
npm install @zsign/convex
// convex/convex.config.ts
import { defineApp } from "convex/server";
import zsign from "@zsign/convex/convex.config.js";
const app = defineApp();
app.use(zsign, {
env: {
ZSIGN_API_KEY: process.env.ZSIGN_API_KEY,
ZSIGN_API_BASE_URL: process.env.ZSIGN_API_BASE_URL,
ZSIGN_WEBHOOK_SECRET: process.env.ZSIGN_WEBHOOK_SECRET,
ZSIGN_WEBHOOK_SECRET_PREVIOUS: process.env.ZSIGN_WEBHOOK_SECRET_PREVIOUS,
},
});
export default app;
Env (component, declared in convex.config.ts)
ZSIGN_API_KEY(required) —zs_live_…/zs_test_…org key.ZSIGN_API_BASE_URL(optional) — defaults tohttps://zsign.io. An origin; a trailing/apior/api/v1(as the zSign API docs write it) is stripped.ZSIGN_WEBHOOK_SECRET(optional) —whsec_…; required to verify webhook signatures.ZSIGN_WEBHOOK_SECRET_PREVIOUS(optional) — previouswhsec_…, accepted in parallel during secret rotation.
App usage
import { Zsign } from "@zsign/convex";
import { components } from "./_generated/api";
const zsign = new Zsign(components.zsign);
// Action: send. operationId is your idempotency/correlation key — replays
// return the stored result instead of creating a second envelope.
await zsign.send(ctx, {
file, filename: "proposal.pdf",
recipients: [{ name, email, role: "client", signingOrder: 1 }],
operationId, // required; also sent as the HTTP Idempotency-Key
metadata: { dealId: "..." }, // strings only; zsign.* keys are reserved
});
// -> { documentId, sessionId, signingUrls, replayed? }
// Queries: reactive reads (subscribe from UI or use in other functions)
await zsign.status(ctx, operationId); // envelope + recipients + completion ids
await zsign.list(ctx, { activeOnly: true }); // non-terminal envelopes
// Actions: canonical sync + artifacts
await zsign.refresh(ctx, operationId); // force a GET against the zSign API
await zsign.getSignedPdf(ctx, completedDocumentId); // -> ArrayBuffer
await zsign.getCertificate(ctx, documentId); // -> ArrayBuffer
Webhooks
Component http routes did not mount on the self-hosted backend this was built against, so the app wires one route itself using the shipped verifier:
// convex/http.ts
import { httpRouter } from "convex/server";
import { verifyWebhookRequest } from "@zsign/convex";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/zsign/webhook",
method: "POST",
handler: httpAction(async (ctx, request) => {
const res = await verifyWebhookRequest(request, [
process.env.ZSIGN_WEBHOOK_SECRET,
process.env.ZSIGN_WEBHOOK_SECRET_PREVIOUS,
]);
if (!res.ok) return new Response(res.error, { status: res.status });
try {
await ctx.runMutation(components.zsign.lib.applyWebhookEvent, res.event);
} catch {
return new Response("webhook receipt unavailable", { status: 503 });
}
return new Response("ok", { status: 200 });
}),
});
export default http;
Verified signature (X-Webhook-Signature: t=…,v1=…, HMAC-SHA256 over
t.{raw_body}, 300s tolerance, either current or previous secret) happens
before any mutation; unknown X-Webhook-Ids are deduped; unknown sessions are
persisted as orphaned events and replayed onto the envelope if it appears
later. A receipt failure returns 503 so zSign retries delivery.
Completion callbacks
// internal mutation the app owns
export const onEnvelopeCompleted = internalMutation({
args: {
callbackId: v.string(), operationId: v.string(), sessionId: v.string(),
documentId: v.string(), completedDocumentId: v.string(),
metadata: v.record(v.string(), v.string()),
},
handler: async (ctx, args) => { /* advance your workflow */ },
});
// drain (e.g. from a cron action)
await zsign.onCompleted(ctx, internal.myFunctions.onEnvelopeCompleted);
Handlers are mutations. Each (envelope, kind) callback is deduped and
persisted; a handler that throws stays failed with lastError and is retried
by the next drain — at-least-once, not exactly-once.
Reconciliation
Webhook-driven state is guarded by generation + status rank; on a conflict
(out-of-order or inconsistent event) the component schedules
reconcile.refreshEnvelope, which fetches GET /api/v1/documents/{id} and
applies canonical state only if the generation still matches, with bounded
backoff. terminal envelopes are never refreshed. lastSyncedAt / syncError
surface per-envelope health; zsign.refresh(ctx, operationId) forces a manual
sync.
Verified end-to-end (2026-09-14)
Stack: self-hosted ghcr.io/get-convex/convex-backend (docker, :3210/:3211),
local zSign backend (:7101, local Postgres + local_gcs_uvicorn.py), webhook
http://127.0.0.1:3211/zsign/webhook.
- Send — component action builds multipart manually (Convex runtime's
FormData drops Blob MIME; zSign 422s without
application/pdf), POSTs withIdempotency-Key= operationId. Tags{signature*:client}auto-assign torole: "client". - Webhooks → reactive status — all lifecycle events delivered, 200 each;
status
completedwithcompletedDocumentId(the completed doc's id —document.completed'sdata.document_idis NOT the original). - Callbacks —
document.completedpersisted anonCompletedcallback; a failing drain recordedlastError, and the next drain retried it tosucceeded. The example app'scompletionsrow was written by its owninternalMutationhandler. - Artifacts — signed PDF (tags redacted, signature stamped) and certificate both return bytes through component actions.
- Signature verification — good sig → applied; replayed
X-Webhook-Id→"duplicate"; bad sig / stalet→ 401;ZSIGN_WEBHOOK_SECRET_PREVIOUSaccepted. - Replay —
sendtwice with the sameoperationId→{replayed: true, same documentId}; with PR #393'sIdempotency-Keythe upstream call also collapses to one envelope + one debit. - Auth wrappers (example) — token-authenticated
sendEnvelope/envelopeStatus/listEnvelopes/refreshEnvelope/ artifact fetches; bad token →unauthenticated, wrong tenant →not found. - Packed artifact —
npm pack→ installed into a clean app dir →convex codegen+tscclean,Zsignclient types resolve.
Failure semantics
- Webhook receipt: verify → dedupe → persist. Unknown envelope →
orphanedrow, re-attached oninsertEnvelopebysessionId. - Send: operation allocated before the HTTP call; a network/5xx failure marks
it
failedand a retry re-sends; asentop short-circuits to the stored result. - Reconcile: generation guard drops stale applies; bounded backoff then stops.
- Callbacks: deduped, persisted, failure-visible, retryable — at-least-once.
zSign has no staging environment, so external-email checks used a dedicated, clearly labeled live account (signup → 0 credits, email verify grants 3, one send debits exactly 1, invitation delivered).
Agent plugin pack
This repo also ships the zSign agent plugin pack at the root — the same files
Cursor uses to connect to the remote MCP server. They are not part of the npm
package (files limits the tarball to src/, docs/, SKILL.md,
CHANGELOG.md).
plugin.json— Agent Plugins 1.0.0 manifestmcp.json— remote Streamable HTTP MCP server athttps://zsign.io/mcp(OAuth 2.1 with Dynamic Client Registration; no API key header once OAuth completes, no secret stored in the pack).cursor-plugin/plugin.json— Cursor logo and homepage (Agent Pluginsplugin.jsonhas no logo field)assets/logo.svg— copied fromfrontend/public/favicon.svg
Prepaid credits, no seats: Starter $27/50 · Growth $87/250 · Scale $297/1000 · White-label $49/mo.