Skip to content

Find a component

Loading the directory…

↑ ↓ to select · Enter to open · Esc to closeBrowse all →
Contributor login

Zsign

zSign e-signature component for Bijection — send documents, watch signing status reactively, and fetch signed artifacts.

Bijection source
git clone https://github.com/bijectionhq/zsign.git
cd zsign
git checkout 3c612ca5f759865c954685033a494739c380659e

Bijection 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.

Available to evaluate

Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.

Getting started

  1. Get the source at the revision above and read its package documentation.
  2. Check the required Bijection SDK version, dependencies, and external service credentials.
  3. Follow the package setup instructions and the guide to using components.
  4. 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.

agent-prompt.md
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 to https://zsign.io. An origin; a trailing /api or /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) — previous whsec_…, 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.

  1. Send — component action builds multipart manually (Convex runtime's FormData drops Blob MIME; zSign 422s without application/pdf), POSTs with Idempotency-Key = operationId. Tags {signature*:client} auto-assign to role: "client".
  2. Webhooks → reactive status — all lifecycle events delivered, 200 each; status completed with completedDocumentId (the completed doc's id — document.completed's data.document_id is NOT the original).
  3. Callbacks — document.completed persisted an onCompleted callback; a failing drain recorded lastError, and the next drain retried it to succeeded. The example app's completions row was written by its own internalMutation handler.
  4. Artifacts — signed PDF (tags redacted, signature stamped) and certificate both return bytes through component actions.
  5. Signature verification — good sig → applied; replayed X-Webhook-Id → "duplicate"; bad sig / stale t → 401; ZSIGN_WEBHOOK_SECRET_PREVIOUS accepted.
  6. Replay — send twice with the same operationId → {replayed: true, same documentId}; with PR #393's Idempotency-Key the upstream call also collapses to one envelope + one debit.
  7. Auth wrappers (example) — token-authenticated sendEnvelope / envelopeStatus / listEnvelopes / refreshEnvelope / artifact fetches; bad token → unauthenticated, wrong tenant → not found.
  8. Packed artifact — npm pack → installed into a clean app dir → convex codegen + tsc clean, Zsign client types resolve.

Failure semantics

  • Webhook receipt: verify → dedupe → persist. Unknown envelope → orphaned row, re-attached on insertEnvelope by sessionId.
  • Send: operation allocated before the HTTP call; a network/5xx failure marks it failed and a retry re-sends; a sent op 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 manifest
  • mcp.json — remote Streamable HTTP MCP server at https://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 Plugins plugin.json has no logo field)
  • assets/logo.svg — copied from frontend/public/favicon.svg

Prepaid credits, no seats: Starter $27/50 · Growth $87/250 · Scale $297/1000 · White-label $49/mo.

Keywords