Automation patterns
Chaindoc doesn't ship a visual workflow builder. what it gives you is a small set of composable primitives (templates, signature requests, contracts, invoices, webhooks, the Embed SDK) that you wire together in your own code. This page walks through four practical recipes that cover most real integrations.
This is a patterns guide, not a reference
For full endpoint and method details, see API documentation and SDKs. This page shows the glue between them. how the primitives connect to form complete flows.
Recipe 1: Template → signature request → status sync
The most common automation: generate a customised document from a template, send it for signing, and react to the result. Use this for any repeatable contract flow (NDAs, employment offers, vendor agreements) where the content changes per recipient but the structure doesn't. Learn more in the ISO/IEC 27001:2022.
- 1Author the template onceBuild the template in the dashboard with variables (
clientName,effectiveDate) and pre-placed signer slots. Publish it. See templates. - 2Render + send from your codeCall
chaindoc.templates.sendForSigning()with the variable values and signer emails. Chaindoc generates the PDF and sends the signing invites. - 3Persist the IDsStore the returned
documentIdandrequestIdin your own database, keyed by your domain entity (deal ID, application ID, whatever fits). - 4Confirm completion on your backendPoll
chaindoc.signatures.getRequestStatus(requestId)from your backend worker and mark your domain entity as signed when the request reachescompleted.
import { Chaindoc } from '@chaindoc_io/server-sdk';
const chaindoc = new Chaindoc({ secretKey: process.env.CHAINDOC_SECRET_KEY! });
export async function sendNdaForDeal(dealId: string, counterpartyEmail: string) {
const { documentId, requestId } = await chaindoc.templates.sendForSigning(
process.env.NDA_TEMPLATE_ID!,
{
documentName: `NDA. deal ${dealId}`,
variables: {
clientName: await getClientName(dealId),
effectiveDate: new Date().toISOString().slice(0, 10),
},
slotAssignments: [
{ signerKey: 'party_a', email: counterpartyEmail },
{ signerKey: 'party_b', email: 'legal@yourcompany.com' },
],
deadline: addDays(new Date(), 14),
},
);
await db.deals.update(dealId, {
chaindocDocumentId: documentId,
chaindocSignatureRequestId: requestId,
status: 'nda_sent',
});
}And a simple status sync worker:
export async function syncNdaStatus(dealId: string) {
const deal = await db.deals.findById(dealId);
if (!deal?.chaindocSignatureRequestId || deal.status === 'nda_signed') return;
const status = await chaindoc.signatures.getRequestStatus(
deal.chaindocSignatureRequestId,
);
if (status.status === 'completed') {
await db.deals.update(deal.id, {
status: 'nda_signed',
signedAt: new Date().toISOString(),
});
}
}Recipe 2: Contract with recurring billing
For ongoing engagements (retainers, subscriptions, monthly services) use a contract with recurring payment terms. Chaindoc handles the signature request on both sides, then you issue invoices against the active contract and listen for payment events.
- 1Create the contractEither via
chaindoc.contracts.create()with your own copy, or viachaindoc.templates.createContract()with a pre-built template. For recurring work, add a payment term withtype: 'recurring'. - 2Send it for signing
chaindoc.contracts.send(). Both sides (business + contragent) are invited in parallel. Trackcontract.signedandcontract.status_changedvia webhooks. - 3Issue invoices on the active contractOnce the contract reaches
ACTIVEstatus, callchaindoc.invoices.create()for each billing period, thenchaindoc.invoices.send()to email the invoice to the counterparty. - 4Track payment eventsListen for
invoice.paid,invoice.cancelled,transaction.created, andtransaction.updatedto mirror payment state into your billing system.
const { contractId } = await chaindoc.contracts.create({
documentId,
title: 'Monthly retainer. Acme',
contragent: { email: 'finance@acme.com', name: 'Acme Inc.' },
currencyCode: 'USD',
paymentTerms: [
{
type: 'recurring',
name: 'Monthly retainer',
amount: '5000.00',
frequency: 'monthly',
startDate: '2026-05-01',
},
],
});
await chaindoc.contracts.send(contractId);
// Later, after contract moves to ACTIVE:
const { invoiceId } = await chaindoc.invoices.create(contractId, {
title: 'Retainer. May 2026',
amount: '5000.00',
currencyCode: 'USD',
dueDate: '2026-05-31T00:00:00Z',
});
await chaindoc.invoices.send(contractId, invoiceId);One contract = two signers, period
Chaindoc contracts model a single counterparty relationship: you (business) + them (contragent). If you need more than two signers on a document, use a standalone signature request instead of a contract. contracts enforce the two-signer shape at the API level.
Recipe 3: Embedded signing inside your web app
Keep users inside your product instead of bouncing them to email. You do the heavy lifting on your backend (create the document, create the signature request, create an embedded session per signer) and your frontend opens the signing UI in a modal or inline iframe.
- 1Backend. create the signature request with `embeddedFlow: true`This tells Chaindoc not to email invites. Create the request the same way as before, just flip the flag.
- 2Backend. create an embedded session per signer
chaindoc.embedded.createSession()returns asessionIdtied to a specific signer on a specific signature request. Generate one session per signer, on demand, when they're ready to sign in your UI. - 3Frontend. open the Embed SDK with the session ID
openSignatureFlow({ sessionId, email, onSuccess, onError, onCancel }). The SDK handles the iframe, OTP verification, and the signing UI. - 4Backend. confirm via webhookDon't trust the browser alone. Confirm completion on the server with the signature request status endpoint, then process any delivered webhooks for related contract, invoice, or transaction state.
// Backend: POST /api/sessions/:userId/start-signing
app.post('/api/sessions/:userId/start-signing', async (req, reply) => {
const user = await db.users.findById(req.params.userId);
const request = await db.signatureRequests.findForUser(user.id);
const session = await chaindoc.embedded.createSession({
email: user.email,
metadata: {
documentId: request.chaindocDocumentId,
signatureRequestId: request.chaindocSignatureRequestId,
},
});
reply.send({ sessionId: session.sessionId });
});// Frontend
import { ChaindocEmbed } from '@chaindoc_io/embed-sdk';
const chaindoc = new ChaindocEmbed({ publicKey: process.env.NEXT_PUBLIC_CHAINDOC_PUBLIC_KEY! });
async function handleSignClick() {
const { sessionId } = await fetch('/api/sessions/me/start-signing', { method: 'POST' }).then((r) => r.json());
await chaindoc.openSignatureFlow({
sessionId,
email: currentUser.email,
theme: 'light',
onSuccess: () => toast.success('Signed!'),
onError: (err) => toast.error(err.message),
onCancel: () => console.log('user closed the modal'),
});
}See SDKs for the full Embed SDK configuration and callback reference, and first signature for the UX your signers will experience.
Recipe 4: Webhook handling that doesn't break under load
Webhooks are the backbone of every Chaindoc automation. Getting them wrong (no signature verification, no idempotency, slow handlers) is the single fastest way to end up with duplicated contracts and mysterious Slack pages at 3am. Two rules cover most cases:
- Verify and deduplicate: use
Chaindoc.webhooks.verify()for the signature check (it also enforces the 5-minute replay window), and use the event'sidas an idempotency key against awebhook_eventstable. - Return fast, process async: webhooks have a short delivery timeout. Acknowledge with a
2xximmediately, then do the real work on a background queue.
app.post('/webhooks/chaindoc', { config: { rawBody: true } }, async (req, reply) => {
// 1. Verify signature + timestamp
const result = Chaindoc.webhooks.verify(
req.rawBody,
req.headers['x-chaindoc-signature'] as string,
req.headers['x-chaindoc-timestamp'] as string,
process.env.CHAINDOC_WEBHOOK_SECRET!,
);
if (!result.valid || !result.envelope) {
return reply.code(401).send({ error: 'invalid signature' });
}
const event = result.envelope;
// 2. Deduplicate by event id
const inserted = await db.webhookEvents.insertIfNew({
id: event.id,
type: event.type,
receivedAt: new Date(),
});
if (!inserted) {
return reply.code(200).send();
}
// 3. Hand off to a queue, ack immediately
await queue.add('process-chaindoc-event', event);
reply.code(200).send();
});Chaindoc retries failed deliveries with exponential backoff before disabling an endpoint. so a handler that returns 500 repeatedly (because the downstream system is broken) will eventually get paused. Keep the handler cheap and let your queue worker do retries with its own policy.
Cross-cutting concerns
Rate limits
Every API key is limited to 10 requests per 60 seconds across all public endpoints. The Server SDK retries on 429 / 5xx automatically with exponential backoff, but if you're processing a batch of hundreds of documents add your own pacing on top. e.g. send no more than 8 requests per minute per worker, with multiple workers keyed on different keys if you need throughput.
Idempotency on your side
The Chaindoc public API doesn't accept idempotency keys today. Guard against double-submits on your side: before calling sendForSigning or contracts.create, check your own DB whether the domain entity (deal, application) already has a Chaindoc document ID attached. If it does, reuse it instead of creating a new one.
Staging vs production
Run the full flow end-to-end in staging before pointing production traffic at it. Staging has its own API keys, webhook secrets, and data. nothing crosses environments. Set environment: 'staging' on the SDK constructor to switch.
What to do next
- API documentation. full endpoint reference for contracts, invoices, templates, and signatures
- SDKs. Server SDK and Embed SDK details including configuration and error handling
- Webhooks. all 17 event types, signature verification, and the 5-minute replay window
- Templates. variable scopes, signer slots, and the three render modes
- Security. key rotation, IP allowlists, and webhook hardening