@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://api.zsign.io.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.