Kamy MCP Server
Kamy merender faktur, kuitansi, kontrak, dan 5 templat kelas produksi lainnya dengan satu panggilan REST atau metode SDK TypeScript. Tanpa headless browser. Tanpa DevOps.
Dokumentasi
01
Install the SDK
The TypeScript SDK works in Node.js, Deno, Bun, and any modern server runtime.
bash
npm install @kamydev/sdk
# or
pnpm add @kamydev/sdk
# or
yarn add @kamydev/sdk
02
Get an API key
Sign in to the dashboard, open API Keys, click New key. Copy the key once — it's shown only on creation.
Environment variable
KAMY_API_KEY=kamy_pk_...
Keep the key on your server only. Never ship it in client-side code.
Scopes. Each key carries a list of scopes that gate which operations it can perform. The dashboard form defaults to all scopes checked; uncheck the ones you don't need to limit blast radius if a key leaks. Available scopes: render, renders:read, templates:read, templates:write, signatures:read, signatures:write, webhooks:read, webhooks:write, schedules:read, schedules:write, uploads:read, uploads:write. A request that hits an endpoint requiring a scope the key doesn't have returns 403 SCOPE_REQUIRED. Keys minted before the scope feature shipped have an empty list and bypass the check (back-compat).
The render scope gates every PDF-producing endpoint: /v1/render, /v1/render/async, /v1/render/bulk, /v1/render-html, /v1/render-docx, /v1/render-xlsx, /v1/render-pptx, /v1/merge, /v1/convert, /v1/pdfs/edit, and /v1/renders/{id}/split. Strip this scope from a key intended only for read-only inspection (renders:read + templates:read) so a leak can't consume your render quota.
Management scopes follow the same pattern: schedules:write gates create/update/delete on /v1/schedules; webhooks:write on /v1/webhooks (incl. the test-delivery endpoint); uploads:write on /v1/uploads (POST + DELETE); templates:write on /v1/templates/{id}, the publish/rollback/versions endpoints, and the POST /v1/templates creator; signatures:write on every state-mutating signature endpoint (envelopes, signature requests, reminders, signature-templates).
The mirror *:read scopes gate the corresponding GET endpoints. renders:read covers the renders list/detail and the extract/pages/jobs sub-resources; templates:read covers template + version reads; schedules:read, webhooks:read, uploads:read, and signatures:read cover the matching list/detail surfaces. The public anonymous catalog (GET /v1/templates with no Authorization header) bypasses scopes entirely — it's IP-rate-limited at the network layer.
03
Quick start
Render an invoice in three lines of TypeScript.
ts
import Kamy from "@kamydev/sdk";
const kamy = new Kamy({ apiKey: process.env.KAMY_API_KEY! });
const pdf = await kamy.render({
template: "invoice",
data: {
invoiceNumber: "INV-001",
issueDate: "2026-01-01",
dueDate: "2026-01-31",
from: { name: "Acme Corp", address: ["123 Main St", "SF, CA"] },
to: { name: "Client Inc", address: ["456 Oak Ave", "NY, NY"] },
lineItems: [
{ description: "Consulting", quantity: 10, unitPrice: 150, amount: 1500 },
],
subtotal: 1500,
total: 1500,
currency: "USD",
},
});
console.log(pdf.url); // signed URL, valid 1 hour
04
Using from a Node backend
Inside an API route (Next.js, Hono, Express, Fastify, Nest) — render on demand and return the URL.
ts
// app/api/invoice/route.ts (Next.js)
import Kamy from "@kamydev/sdk";
export async function POST(req: Request) {
const body = await req.json();
const pdf = await kamy.render({ template: "invoice", data: body });
return Response.json({ url: pdf.url });
}
05
Using from the frontend
Important: API keys must never be embedded in frontend code. Call your own backend, which calls Kamy. The browser only sees the resulting signed URL.
ts
// client-side React
async function downloadInvoice(data: InvoiceData) {
const res = await fetch("/api/invoice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const { url } = await res.json();
window.open(url, "_blank");
}
06
Raw REST API
If you're not in a JS environment, call the REST endpoint directly.
bash
curl -X POST https://kamy.dev/api/v1/render \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template": "invoice",
"data": { "invoiceNumber": "INV-001", "total": 1500, "currency": "USD" }
}'
Response: { id, url, bytes, durationMs, templateId, createdAt }
Response headers include X-Kamy-Cache: hit when the PDF was served from the response cache (see Response caching) and X-Kamy-Cache: miss otherwise. Both hits and misses count against your render quota and bill identically — only compute time differs.
Prefer Postman, Insomnia, or Bruno? Import the official collection — postman/kamy.json — covers all 23 v1 routes with sample bodies, path-variable defaults, and Bearer auth pre-wired to a single {{apiKey}} collection variable.
07
Built-in templates
Reference a template by slug. Each has a fully typed data schema (see InvoiceData, ReceiptData, etc. exported from the SDK). Every registered system template is discoverable via GET /v1/templates and GET /v1/templates/{slug} regardless of whether anyone on your account has rendered it yet — the catalogue self-mirrors on first read, with a per-slug fallback so a single bad row in the seed never hides the rest.
GET /v1/templates is also reachable without an API key — anonymous callers receive the public catalogue (system templates plus customs explicitly flagged is_public: true), gated by a 30 req / min / IP rate limit. Authenticated callers additionally see their own custom templates and use the standard byKey / byUser limits. This makes the catalogue safe to curl from a docs page or a discovery agent without first signing up.
| Slug | Name | Description |
|---|---|---|
| invoice | Invoice | Line items, tax, discounts, payment terms. |
| receipt | Receipt | Compact thermal-style, monospace amounts. |
| quote | Quote | Validity date, quote number, line items. |
| contract | Contract | Numbered sections, signature blocks. |
| shipping-label | Shipping Label | 4×6 inch with barcode and tracking. |
| certificate | Certificate | Decorative border, signature line. |
| report | Report | Cover, TOC, auto headers/footers. |
| agreement | Agreement | One-page with parties and terms. |
| uae-tax-invoice | UAE Tax Invoice | FTA-compliant bilingual AR/EN, 5% VAT. |
| ksa-zatca-invoice | KSA ZATCA Invoice | ZATCA Phase-1 simplified, TLV QR. |
08
Custom Handlebars templates
Upload your own HTML/Handlebars templates from the Templates page — or push them straight from CI with the kamy push CLI so your production templates stay in lockstep with your repo on every commit.
bash
# CI: idempotent upsert keyed on slug, safe to re-run
npm i -g @kamydev/cli
export KAMY_API_KEY=kamy_pk_...
kamy push templates/invoice.hbs --css templates/invoice.css --tag finance
ts
// Or from the SDK
await kamy.pushTemplate({
slug: "invoice-acme", // creates if missing, updates if present
name: "Acme Invoice",
html: await fs.readFile("invoice.hbs", "utf8"),
css: await fs.readFile("invoice.css", "utf8"),
});
// Then render by slug just like a built-in
await kamy.render({
template: "invoice-acme",
data: { orderId: "123", items: [/* … */] },
});
Patching an existing template? updateTemplate() accepts either a UUID or a slug as its first argument — no need to GET-then-PATCH-by-id. Same for deleteTemplate().
ts
// Slug-keyed PATCH — single round trip
await kamy.updateTemplate("invoice-acme", {
name: "Acme Invoice (Q2 redesign)",
html: updatedHbs,
});
// Slug-keyed DELETE
await kamy.deleteTemplate("invoice-acme");
Handlebars helpers available: currency, date, add, number, plus all built-ins (each, if, unless). Validate payloads against your schema in CI without burning credits by passing options.validateOnly: true.
09
Asset uploads (large images, fonts, logos)
Render requests are capped at 6 MB of JSON body. Anything larger (high-resolution photos, multi-page brochure imagery, bundled font files) should be uploaded once via createUpload() and then referenced from your template by URL — same render call, fraction of the bytes on the wire.
Uploads use a two-step pattern: ask Kamy for a pre-signed PUT URL, stream the file body straight to storage, then embed the returned publicUrl — or the shorthand kamy://asset/<id> URI — anywhere in your render data. The render route resolves kamy:// URIs to fresh signed URLs automatically, so you never have to manage signed-URL expiry yourself.
What expiresAt means. The expiresAt timestamp returned by POST /v1/uploads applies to the pre-signed uploadUrl only — that PUT URL is single-use and valid for 15 minutes. The asset itself never expires: once the PUT succeeds and the row flips to status: "uploaded", the kamy://asset/<id> reference is rendered for the lifetime of the asset (deletable via DELETE /v1/uploads/{id}). You can safely cache the kamy:// reference indefinitely on your side and reuse it across as many renders as you like — every render mints a fresh short-lived signed download URL internally, so you do not need to re-upload to keep refs valid.
ts
import { readFile } from "node:fs/promises";
// 1. Ask Kamy for a pre-signed PUT URL (15-min single-use).
const upload = await kamy.createUpload({
filename: "hero.jpg",
contentType: "image/jpeg",
sizeBytes: 4_200_000, // optional pre-flight check vs 100 MB cap
});
// 2. Stream the file body to Supabase Storage with PUT (NOT POST).
await fetch(upload.uploadUrl, {
method: "PUT",
headers: { "Content-Type": "image/jpeg" },
body: await readFile("./hero.jpg"),
});
// 3a. Reference the long-lived publicUrl directly in your data…
await kamy.render({
template: "flyer",
data: { heroImage: upload.publicUrl },
});
// 3b. …or use the kamy:// shorthand. The render route auto-resolves
// it to a freshly-signed URL on every render, so no expiry to manage.
await kamy.render({
template: "flyer",
data: { heroImage: `kamy://asset/${upload.path.split("/").pop()}` },
});
Hard cap is 100 MB per object. If a render request still hits the 6 MB limit after switching to uploads, you'll get a structured 413 PAYLOAD_TOO_LARGE with the exact limit and remediation hint in the error body.
09b
Schedules + integrations
Recurring deliveries. POST /api/v1/schedules to set up a cron-driven render that fires on your timetable and lands as a PDF in your renders log, an inbox, or a WhatsApp chat. Every 5 minutes a worker picks up due schedules, renders the template against the saved data, and dispatches via the configured channel.
bash
curl -X POST https://kamy.dev/api/v1/schedules \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Weekly Acme invoice",
"template": "invoice",
"data": { "invoiceNumber": "INV-WEEKLY", "currency": "USD" /* … */ },
"channel": "email",
"recipients": ["[email protected]"],
"schedule": "0 9 * * 1", // every Monday 09:00
"timezone": "Asia/Dubai"
}'
Channels: email (via Resend), whatsapp (via Meta Cloud API — requires WHATSAPP_PHONE_NUMBER_ID + WHATSAPP_ACCESS_TOKEN on the deployment), or download (no delivery; the render lands in your renders log for the dashboard / API to fetch).
Branding. Scheduled renders go through the same brand-kit auto-merge as direct POST /v1/render calls — your saved logo, accent color, font, and footer text are folded into the rendered PDF without any extra configuration. Set data.brand on the schedule to override individual fields, or leave it empty to use whatever's in /dashboard/brand-kit. Free-tier accounts also receive the same Generated by Kamy footer on scheduled output that POST /v1/render applies — schedules are no longer a free-tier branding bypass.
Manage via GET /api/v1/schedules, PATCH /api/v1/schedules/{id}, and DELETE /api/v1/schedules/{id}, or visit /dashboard/schedules for a UI with cron presets, recipient validation, and a live preview of the next firings.
Zapier / Make / n8n. Kamy works with every no-code automation platform that can hit a REST endpoint with a Bearer token — no special connector required. Configure a custom webhook action in your tool of choice, point it at POST https://kamy.dev/api/v1/render, set the Authorization header to Bearer YOUR_KAMY_API_KEY, and pass { template, data } as the body. The response gives you a signed PDF URL you can pipe into the next step (Slack, Drive, Email, S3 — anything that takes a URL). Combine with Kamy webhooks (render.completed) to fire downstream actions when an async render finishes.
10
Async, batch, bulk & merge
For long-running renders, fire-and-forget jobs, and bulk pipelines.
ts
// Async — enqueue and poll
const job = await kamy.renderAsync({ template: "report", data });
const pdf = await job.wait({ pollIntervalMs: 1000, timeoutMs: 120_000 });
// Batch — up to 100 renders in one request
const { results } = await kamy.renderBatch([
{ template: "invoice", data: { invoiceNumber: "INV-001" /* … */ } },
{ template: "receipt", data: { receiptNumber: "REC-002" /* … */ } },
]);
// Merge — combine 2–20 rendered PDFs into one document
const merged = await kamy.merge([pdf1.id, pdf2.id, pdf3.id]);
// Idempotency — safe to retry without double-charging
await kamy.render({
template: "invoice",
data,
idempotencyKey: "order-12345", // any unique string up to 64 chars
});
// Download helpers
await pdf.toFile("./invoice.pdf"); // write to disk
const buf = await pdf.toBuffer();
const stream = await pdf.toStream();
Batch response codes — each item in results is either a render object or an { error: { code, message } } shape (discriminate with "error" in item). The HTTP status reflects the aggregate outcome: 200 all succeeded, 207 partial success, 502 all failed. Always iterate results — never assume a 2xx means every item rendered.
Async + scheduled output parity. /v1/render/async and scheduled renders go through the same asset-inlining and watermark pipeline as /v1/render. Remote <img> sources and Google Fonts <link> tags are inlined server-side before Chromium runs, eliminating the network-stall surface that used to make async + cron renders slower than their sync counterparts. Free-tier accounts also get the same per-cycle Generated by Kamy footer behavior across all three paths.
Bulk / mail-merge — for the classic CSV → many-PDFs flow (one template, many rows of data), POST /api/v1/render/bulk returns a single ZIP archive instead of an array of URLs. Capped at 25 rows per call so it fits inside the platform's wall-clock budget; for larger batches call /v1/batch directly and chunk client-side.
bash
curl -X POST https://kamy.dev/api/v1/render/bulk \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template": "invoice",
"rows": [
{ "data": { "invoiceNumber": "INV-001", "from": {...}, "to": {...}, "lineItems": [...], "total": 1500, "currency": "USD" }, "name": "acme-q1" },
{ "data": { "invoiceNumber": "INV-002", "from": {...}, "to": {...}, "lineItems": [...], "total": 2250, "currency": "USD" }, "name": "globex-q1" }
]
}' \
--output bulk.zip
# Response headers: X-Bulk-Total, X-Bulk-Rendered, X-Bulk-Failed
# ZIP contains one .pdf per success + manifest.json. Failed rows
# land as <name>.error.json so partial bulks remain salvageable.
HTML output — when you want the same template + data pipeline piped into transactional email (Resend, SendGrid, Mailchimp) instead of a PDF, POST /api/v1/render-html skips the Chromium pipeline and returns the rendered HTML as a string. Counts as one render against your monthly quota.
bash
curl -X POST https://kamy.dev/api/v1/render-html \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "template": "invoice", "data": { "invoiceNumber": "INV-001" /* … */ } }'
# → { "format": "html", "html": "<!DOCTYPE html>…", "bytes": 12480 }
10b
XLSX & PPTX generation
Same template-driven mental model as PDF, different output. Spec-based for v1 — pass a structured JSON body and Kamy emits the file. One render per call counts against your monthly quota; no per-format limits.
XLSX — POST /api/v1/render-xlsx with one or more sheets, each declaring columns + rows. Returns the workbook as binary. Headers get auto-styled (bold + light-grey fill); pass totalRow for a pinned bottom row (string keywords SUM / AVG / COUNT / MIN / MAX auto- expand to =SUM(D2:D6)-style formulas; =-prefixed strings pass through verbatim), or per-column numFmt for currency / percent / date formatting.
bash
curl -X POST https://kamy.dev/api/v1/render-xlsx \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-o invoices.xlsx \
-d '{
"title": "Invoices · Q2",
"sheets": [{
"name": "Open invoices",
"columns": [
{ "header": "Invoice #", "key": "id" },
{ "header": "Customer", "key": "customer", "width": 32 },
{ "header": "Issued", "key": "issued", "numFmt": "yyyy-mm-dd" },
{ "header": "Amount", "key": "amount", "numFmt": "#,##0.00" }
],
"rows": [
{ "id": "INV-001", "customer": "Acme Inc", "issued": "2026-04-01", "amount": 1500 },
{ "id": "INV-002", "customer": "Globex Co.", "issued": "2026-04-10", "amount": 3200 }
],
"totalRow": { "id": "Total", "amount": "SUM" }
}]
}'
PPTX — POST /api/v1/render-pptx with an array of slides, each tagged with one of the v1 layouts: title, bullets, two-column, table, quote. Pass theme.accentHex + an optional theme.fontFace for branding.
bash
curl -X POST https://kamy.dev/api/v1/render-pptx \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-o weekly-update.pptx \
-d '{
"title": "Q2 weekly · 2026-04-29",
"format": "WIDE",
"theme": { "accentHex": "var(--paper-primary)" },
"slides": [
{ "layout": "title", "title": "Q2 weekly", "subtitle": "Engineering · 2026-04-29" },
{ "layout": "bullets", "title": "What shipped", "bullets": ["Tier 1 + 2 of expansion plan", "5 new system templates", "Schedules + WhatsApp surface"] },
{ "layout": "table", "title": "Render volume", "headers": ["Plan", "This week", "MoM"],
"rows": [["Free","8.2k","+12%"], ["Starter","41k","+18%"], ["Scale","112k","+22%"]] }
]
}'
10c
E-signature
Send any rendered PDF for signature, capture the drawn signature on a public link, and get the stamped PDF emailed back to both parties. Visual signatures (canvas drawing, not PKI) — same legal weight as a hand-drawn signature on a printed contract. Both the invite and the signed-copy notification are sent as branded HTML with your account name and the document title so the recipient sees who's asking and what they're signing instead of a bare URL paste.
bash
# Option A — render first, then sign
RENDER_ID=$(curl -s -X POST https://kamy.dev/api/v1/render \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "template": "mutual-nda", "data": { /* … */ } }' | jq -r .id)
curl -X POST https://kamy.dev/api/v1/signatures \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"renderId": "'$RENDER_ID'",
"signerEmail": "[email protected]",
"signerName": "Jane Smith",
"message": "Looking forward to working together."
}'
# Option B — sign an existing PDF directly (no render step)
curl -X POST https://kamy.dev/api/v1/signatures \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"pdfUrl": "https://storage.example.com/contract-v3.pdf",
"signerEmail": "[email protected]",
"signerName": "Jane Smith"
}'
# → { id, sign_url, sign_token, expires_at, ... }
Pass renderId (existing render) or pdfUrl (any publicly reachable PDF — Kamy fetches and stores it server-side). Optionally pass position: { page, x, y, w, h } in PDF points (origin bottom-left) for precise placement; default is bottom-right of the last page. Pass signOnEveryPage: true to stamp the recipient's single drawn signature on every page of the source PDF — common for multi-page B2B contracts. Pass requireStamp: true to require a company stamp / seal in addition to the personal signature — the recipient uploads a stamp image at sign time and the server composites both onto the PDF (UAE, KSA, JP, KR, IN, CN B2B workflows). Fetch a single request with GET /api/v1/signatures/{id}, list all with GET /api/v1/signatures, cancel with PATCH /api/v1/signatures/{id} ({ "action": "void" }), and resend the invite with POST /api/v1/signatures/{id}/remind (rate-limited to once per hour — returns HTTP 429 with Retry-After if too soon). Pass reminderCadenceHours on the create call (24–168) to auto-remind on a schedule — the worker resends the invite every N hours while pending, up to 3 reminders total. For higher-value transactions (real estate, employment, financial) pass authMethod: "email_otp" — the sign page renders an OTP gate before the document loads, the signer enters a 6-digit code we email to signerEmail, document unlocks on verify (uses the same Resend channel as the invite, no extra env). SMS OTP is also supported via authMethod: "sms_otp" + signerPhone (E.164) and requires TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER in env — currently on hold from the dashboard UI but live on the API. Fires signature.opened on first sign-page load and signature.voided on void. For sales-team workflows that fan one rendered PDF out to a list of signers (NDAs, MSAs, onboarding agreements), use POST /api/v1/signatures/bulk with up to 100 signers in a single request — each row produces an independent signature_requests row + invite email and the response returns per-row success / failure with HTTP 207 when any row failed. Every terminal signature request (signed, declined, delegated, voided, expired) exposes a Certificate of Completion PDF — fetch via GET /api/v1/signatures/{id}/certificate (Bearer auth,signatures.read scope) or token-auth at /api/sign/{token}/certificate. The PDF records the full lifecycle (invite → opened → consent → signed/declined/delegated, with timestamps, IP, user agent, ESIGN/UETA consent acknowledgment) and cross-links to the cryptographic /verify/{sha256} page when the signed PDF was PAdES-sealed. Manage from /dashboard/signatures.
For flat PDFs (Word exports, scanned contracts) that don't ship AcroForm widgets, attach placedFields on the create request — the signer page renders fillable inputs at the configured PDF coordinates (origin bottom-left, points), and the server stamps submitted values onto the page before applying the signature. Field types: text, textarea, checkbox, date, initials, radio, dropdown. Pass options: ["…"] for radio/dropdown to constrain choices. Up to 100 fields per request, names must be unique.
Templates can carry their own default signing config — set signature_position, stamp_position, placed_fields, requires_stamp, and sign_on_every_page on the template row and every signature request created against a render of that template inherits them automatically. Lets you place a signature box once on an NDA template and have every send reuse it. Precedence: request body → signatureTemplateId → template defaults → server bottom-right fallback.
bash
curl -X POST https://kamy.dev/api/v1/signatures \
-H "Authorization: Bearer $KAMY_API_KEY" \
-d '{
"renderId": "'$RENDER_ID'",
"signerEmail": "[email protected]",
"signerName": "Jane Smith",
"expiresIn": 604800,
"ccEmails": ["[email protected]"],
"placedFields": [
{ "name": "fullName", "type": "text",
"page": 1, "x": 100, "y": 600, "w": 220, "h": 22,
"required": true, "signerLabel": "Your full legal name" },
{ "name": "initials", "type": "initials",
"page": 1, "x": 400, "y": 600, "w": 60, "h": 22 },
{ "name": "jurisdiction", "type": "dropdown",
"page": 1, "x": 100, "y": 560, "w": 180, "h": 22,
"options": ["England & Wales", "New York", "UAE DIFC"] },
{ "name": "agreeTerms", "type": "checkbox",
"page": 1, "x": 100, "y": 520, "w": 18, "h": 18, "required": true }
]
}'
Add an anchor string to any placed field and the server locates the matching text in the PDF and positions the field there. Pair anchor with x/y to offset from the match. Pass signatureTemplateId (from POST /api/v1/signature-templates) to apply a reusable default set of placedFields, position, message, expiresIn, and ccEmails — per-request fields always override template defaults. The merged request is re-validated against the same request schema (bounds, type checks) before the signing pipeline runs, so a template row that predates stricter validation cannot bypass current input rules.
For multi-signer flows use POST /api/v1/envelopes instead. Supply 2–10 recipients; each gets an independent sign link against the same source PDF (parallel routing). The envelope status becomes completed when the last recipient signs. Void all pending requests in one call with PATCH /api/v1/envelopes/{id} ({ "action": "void" }) — fires signature.voided per recipient and signature.envelope_completed / signature.envelope_voided at the envelope level. Token expiry defaults to 30 days; pass expiresIn (seconds, 3 600–2 592 000) to override. CC up to 10 observer addresses via ccEmails — they receive the invite copy and the signed-PDF notification. The signed PDF lands in your renders log alongside any other render.
10d
Multi-signer envelopes
Send one PDF to 2–10 recipients simultaneously. Each gets an independent sign link; the envelope status becomes completed when the last recipient signs. Use routing: "sequential" to gate each invite behind the previous signer — recipient 2 receives their link only after recipient 1 completes.
bash
curl -X POST https://kamy.dev/api/v1/envelopes \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"renderId": "'$RENDER_ID'",
"routing": "parallel",
"message": "Please review and sign.",
"expiresIn": 604800,
"ccEmails": ["[email protected]"],
"recipients": [
{ "email": "[email protected]", "name": "Alice Smith", "order": 1 },
{ "email": "[email protected]", "name": "Bob Jones", "order": 2 }
]
}'
# → { envelope: { id, status: "pending", routing, … }, recipients: [{ sign_url, … }, …] }
# Void all pending requests in one call:
curl -X PATCH https://kamy.dev/api/v1/envelopes/$ENVELOPE_ID \
-H "Authorization: Bearer $KAMY_API_KEY" \
-d '{ "action": "void" }'
Fetch envelope status + all recipients with GET /api/v1/envelopes/{id}. List paginated with GET /api/v1/envelopes. Voiding fires signature.voided per affected recipient and signature.envelope_voided at the envelope level. Completion fires signature.envelope_completed.
10e
Signature templates
Store reusable defaults — placedFields, position, message, expiresIn, ccEmails — and reference the template by ID on any signature request. Per-request fields always override template defaults.
bash
# Create a template
curl -X POST https://kamy.dev/api/v1/signature-templates \
-H "Authorization: Bearer $KAMY_API_KEY" \
-d '{
"name": "NDA — standard",
"message": "Please sign the attached NDA.",
"expiresIn": 604800,
"ccEmails": ["[email protected]"],
"placedFields": [
{ "name": "fullName", "type": "text",
"page": 1, "x": 80, "y": 650, "w": 220, "h": 22,
"anchor": "Signatory name", "required": true }
]
}'
# → { id: "tpl_…", name, placed_fields, … }
# Use it in a signature request
curl -X POST https://kamy.dev/api/v1/signatures?preview=1 \
-H "Authorization: Bearer $KAMY_API_KEY" \
-d '{
"renderId": "'$RENDER_ID'",
"signerEmail": "[email protected]",
"signerName": "Jane Smith",
"signatureTemplateId": "'$TPL_ID'"
}'
Manage templates with GET /api/v1/signature-templates (list), GET /api/v1/signature-templates/{id} (detail), PATCH /api/v1/signature-templates/{id} (update), and DELETE /api/v1/signature-templates/{id}.
10f
Cryptographic sealing (PAdES)
Seal a render with an X.509 certificate so any tampering breaks the signature. Output is an ETSI EN 319 142-1 PAdES-B-LT signature: the basic seal (B-B), an RFC 3161 timestamp from a public TSA embedded as an unsigned attribute on the SignerInfo (B-T), and a CRL revocation snapshot embedded in the PKCS#7 SignedData so verifiers can check revocation offline (B-LT). Signed under Kamy's in-house CA — recipients verify authenticity at kamy.dev/verify.
bash
# Seal an existing render with a PAdES X.509 signature.
curl -X POST https://kamy.dev/api/v1/sign/$RENDER_ID \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Approved by Finance",
"location": "Dubai, UAE",
"withTimestamp": true
}'
# → { signed_pdf_url, signed_pdf_sha256, cert_id,
# timestamped, has_revocation_info, verify_url, ... }
Pass withTimestamp: false to skip the TSA round-trip (PAdES-B-B instead of B-T) — useful for offline / air-gapped renders. The response's verify_url resolves to kamy.dev/verify, where any recipient can drag-drop the signed PDF — the SHA-256 is computed in-browser via SubtleCrypto (the file never uploads), then matched against the signing_events ledger to surface signer identity, cert chain, and the embedded timestamp.
CRL distribution point on every leaf cert resolves to https://kamy.dev/api/verify/crl — Acrobat and openssl follow it automatically when no embedded CRL is present. CLI: kamy sign <render-id> wraps the same endpoint; kamy verify <file.pdf> hashes a local PDF and prints its verify URL with no API call.
10e
Document utilities — convert, split, pull text & pages
Four endpoints that work on any render already in your library (or accept new files directly). None count against your render quota except POST /api/v1/convert, which is billed as one render.
Not the same as Kamy Ingest. These utilities pull text or AcroForm field values out of a render Kamy already produced. To extract structured JSON from an arbitrary inbound PDF (vendor invoice, claim form, contract you received) with a public verify URL, see Kamy Ingest — different endpoint, different superpower.
Convert DOCX / XLSX / CSV → PDF
Upload a Word document, spreadsheet, or CSV and receive a standard RenderResult. Uses mammoth for DOCX fidelity and SheetJS for spreadsheet tables — no LibreOffice dependency on your end.
bash
curl -X POST https://kamy.dev/api/v1/convert \
-H "Authorization: Bearer $KAMY_API_KEY" \
-F "[email protected]" \
-F "name=Q3 Contract"
# → { id, url, bytes, durationMs, name, createdAt }
Split by page range
Split a render into N new renders — one per range. Ranges are 1-based and inclusive. Omit to to extend to the last page. Up to 50 ranges per call.
ts
const { renders } = await kamy.split({
id: "rnd_abc",
ranges: [
{ from: 1, to: 3, name: "Cover + Terms" },
{ from: 4, name: "Appendix" }, // to the end
],
});
// renders[0].url — signed URL for pages 1-3
Pull text or AcroForm fields from a render
Works against a render already in your library — GET /api/v1/renders/{id}/extract. Different endpoint from POST /api/v1/extract (Kamy Ingest, which takes any inbound PDF and returns structured JSON + verify URL).
type=text returns per-page text blocks plus a fullText string. type=fields returns AcroForm field names, types, and current values — useful for auditing filled forms.
ts
const text = await kamy.extract({ id: "rnd_abc" });
// text.fullText — joined plaintext
// text.pages — [{ page: 1, text: "…" }, …]
const fields = await kamy.extract({ id: "rnd_abc", type: "fields" });
// fields.fields — [{ name: "signatureDate", type: "text", value: "2025-01-01" }]
Rasterise pages to PNG
Every page becomes a signed PNG URL (1-hour expiry). Default 150 DPI for screen previews; pass dpi=300 for print-quality thumbnails. Images are stored under pdfs/{userId}/pages/{renderId}/ and overwritten on repeated calls.
ts
const { pages } = await kamy.renderPages({ id: "rnd_abc", dpi: 150 });
// pages[0] — { page: 1, width: 1240, height: 1754, url: "https://…" }
10g
PDF editing — fill, stamp & redact
Edit an existing PDF before sending it for signature — fill AcroForm fields, stamp text at exact coordinates, or paint opaque redaction boxes over sensitive content. The result is saved as a new render that you can pass directly to POST /api/v1/signatures or POST /api/v1/envelopes.
Supply either a renderId (an existing render in your library) or a pdfUrl (any publicly reachable PDF — Kamy fetches, edits, and stores it). Up to 50 operations per call, applied in order.
Operations
fill_field— write a value into a named AcroForm widget (text, checkbox, radio, dropdown). PassflattenFields: true(default) to bake the values into the page so they are no longer editable.stamp_text— draw a text string at a PDF user-space coordinate (origin bottom-left). SupportsfontSize,color(hex), andopacity.redact— paint a filled rectangle over a region. Visual only — the response includes aREDACT_VISUAL_ONLYwarning as a reminder that the underlying bytes are not removed.
bash
# Edit a client-uploaded lease agreement, then send for signature.
curl -X POST https://kamy.dev/api/v1/pdfs/edit \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"pdfUrl": "https://example.com/lease-template.pdf",
"operations": [
{ "op": "fill_field", "field": "TenantName", "value": "Alice Smith" },
{ "op": "fill_field", "field": "StartDate", "value": "2026-06-01" },
{ "op": "fill_field", "field": "AgreeTerms", "value": true },
{ "op": "stamp_text", "page": 4,
"x": 72, "y": 120, "text": "Ref: LSE-2026-001",
"fontSize": 9, "color": "#888888" },
{ "op": "redact", "page": 2,
"x": 310, "y": 540, "w": 160, "h": 18 }
]
}'
# → { id, url, bytes, durationMs, name, warnings }
ts
// SDK — fill → sign in two lines
const edited = await kamy.pdfs.edit({
pdfUrl: "https://example.com/lease-template.pdf",
operations: [
{ op: "fill_field", field: "TenantName", value: "Alice Smith" },
{ op: "fill_field", field: "StartDate", value: "2026-06-01" },
{ op: "stamp_text", page: 4, x: 72, y: 120,
text: "Ref: LSE-2026-001", fontSize: 9 },
],
});
const sig = await kamy.signatures.create({
renderId: edited.id,
signerEmail: "[email protected]",
signerName: "Alice Smith",
});
All coordinates are PDF user-space points (72 pt = 1 inch, origin bottom-left) — the same system used by placedFields throughout the e-signature API. Use GET /api/v1/renders/{id}/extract?type=fields to list AcroForm field names in your source PDF before filling.
11
Webhooks
Subscribe to render.completed, render.failed, batch.completed, and merge.completed events. Each delivery is signed with HMAC-SHA256 — verify with verifyWebhook before processing.
ts
// 1. Create a subscription
const hook = await kamy.webhooks.create({
url: "https://example.com/hooks/kamy",
events: ["render.completed", "render.failed"],
});
// Save hook.secret — it is shown only once.
// 2. Verify deliveries in your handler
import { verifyWebhook } from "@kamydev/sdk";
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("x-kamy-signature") ?? "";
const ok = await verifyWebhook({
body,
signature: sig,
secret: process.env.KAMY_WEBHOOK_SECRET!,
});
if (!ok) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(body);
// event.type, event.data.render, event.data.jobId
return new Response("ok");
}
12
Quota & usage (programmatic)
Two read-only endpoints expose plan limits and live usage so you can wire dashboards, quota meters, or CI pre-flight checks without scraping invoice emails or guessing when you'll hit the wall.
ts
// Plan + static limits — call once at app boot, cache it.
const me = await kamy.me();
console.log(me.plan); // "free" | "starter" | "pro" | "business" | "scale"
console.log(me.limits.rendersPerMonth); // number, or null for unlimited (Scale)
console.log(me.limits.customTemplates); // boolean
console.log(me.limits.seats); // number
// Live UTC-calendar-month usage — cheap, safe to poll.
const usage = await kamy.usage();
console.log(usage.renders.used); // 1_247
console.log(usage.renders.quota); // 25_000 (Pro plan; null on Scale)
console.log(usage.renders.remaining); // 8_753 (null on unlimited tiers)
console.log(usage.period.start, usage.period.end);
// Pre-flight before a bulk job
if (usage.renders.remaining !== null && usage.renders.remaining < jobs.length) {
throw new Error(`Need ${jobs.length} renders, only ${usage.renders.remaining} left.`);
}
Both endpoints are quota-free — they don't count against your monthly render budget. me() changes only on plan upgrades, so cache it; usage() is the one to poll.
13
Error handling
ts
import Kamy, { KamyError } from "@kamydev/sdk";
try {
const pdf = await kamy.render({ template: "invoice", data });
} catch (err) {
if (err instanceof KamyError) {
console.error(err.code); // e.g. "QUOTA_EXCEEDED"
console.error(err.status); // HTTP status
console.error(err.message); // Human-readable
}
}
| Code | HTTP | Description |
|---|---|---|
| UNAUTHORIZED | 401 | Missing or invalid API key |
| INVALID_API_KEY | 401 | Key format invalid |
| API_KEY_REVOKED | 401 | Key was revoked |
| FORBIDDEN | 403 | Access denied |
| SCOPE_REQUIRED | 403 | API key lacks the scope required for this operation |
| NOT_FOUND | 404 | Template, render, or signature request not found |
| VALIDATION_ERROR | 422 | Request body failed schema validation |
| RATE_LIMITED | 429 | Too many requests |
| QUOTA_EXCEEDED | 402 | Monthly render limit reached |
| PAYLOAD_TOO_LARGE | 413 | Request body exceeded the 6 MB JSON limit |
| RENDER_FAILED | 500 | PDF generation failed |
| INVALID_PDF_URL | 422 | pdfUrl could not be fetched or is not a valid PDF |
| INVALID_STATUS | 409 | Action not allowed for the resource's current status |
| REMIND_TOO_SOON | 429 | Reminder already sent — retry after Retry-After seconds |
| PREVIEW_REQUEST | 410 | Preview rows cannot be signed or reminded |
| ALREADY_SIGNED | 410 | Signature request has already been signed |
| ALREADY_VOIDED | 409 | Signature request is already voided |
| EXPIRED | 410 | Sign link has expired |
14
MCP server (Claude, Cursor, Replit, ChatGPT)
Kamy exposes a Streamable HTTP MCP server at https://mcp.kamy.dev/mcp so any AI client that speaks the Model Context Protocol can render PDFs, request signatures, and verify documents directly from a chat prompt.
JSON-config clients (Claude Desktop, Claude Code, Cursor, Continue, Zed)
Drop into your client's MCP config file:
json
{
"mcpServers": {
"kamy": {
"url": "https://mcp.kamy.dev/mcp",
"headers": { "Authorization": "Bearer kamy_pk_..." }
}
}
}
For Claude Code specifically: claude mcp add --transport http kamy https://mcp.kamy.dev/mcp --header "Authorization: Bearer $KAMY_API_KEY".
Form-based clients (Replit, n8n, Make.com, Zapier, custom dashboards)
Most web-based MCP integrations expect three values in their connection form:
| Field | Value |
|---|---|
| Display name / Server name | Kamy (or any label you want) |
| Server URL / Base URL | https://mcp.kamy.dev/mcp (include the /mcp suffix) |
| Custom header — name | Authorization (capital A; this exact string) |
| Custom header — value | Bearer kamy_pk_… (the literal word Bearer, then a space, then your key) |
Common pitfall: putting the API key under a custom header named kamy or api-key. The MCP server only reads the API key from Authorization: Bearer kamy_pk_… (or, as a fallback, X-Kamy-Api-Key: kamy_pk_… with no Bearer prefix). Any other header name is ignored and the connection will fail with a 401.
Verifying your key works (curl)
If your client surfaces a confusing error like "Session terminated" or "connection failed", run this from your terminal with the same API key:
bash
curl -X POST https://mcp.kamy.dev/mcp \
-H "Authorization: Bearer $KAMY_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_signature_requests","arguments":{}}}'
A 200 response with a JSON-RPC result block proves the key, header, and Kamy MCP server are all healthy — any failure you see in your AI client is happening on the client's side, not Kamy's.
Available tools (10)
list_templates, render_pdf, install_sdk, generate_integration_code, get_api_key_instructions, ask_kamy, create_signature_request, list_signature_requests, verify_pdf_signature, pki_sign_pdf. Tools that mutate account state (render, signature operations) require an API key; list_templates, install_sdk, generate_integration_code, get_api_key_instructions, ask_kamy, and verify_pdf_signature work anonymously.
15
Render options
Every render method accepts an options object to control page layout, running headers/footers, and PDF security.
ts
await kamy.render({
template: "report",
data,
options: {
format: "letter", // "a4" | "a3" | "letter" | "legal" (default: "a4")
orientation: "landscape", // "portrait" | "landscape" (default: "portrait")
margin: { top: "20mm", right: "15mm", bottom: "20mm", left: "15mm" },
// Running header injected above every page
header: {
html: `<div style="font-size:9px;color:#999;text-align:right;width:100%">
My Report — page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div>`,
height: "12mm",
},
// Running footer
footer: {
html: `<div style="font-size:9px;color:#999;text-align:center;width:100%">
© 2026 Acme Corp — Confidential
</div>`,
height: "10mm",
},
pageNumbers: true, // auto page numbers via class="pageNumber" in header/footer
// AES-256 encryption
encrypt: {
userPassword: "open-secret", // required to open the PDF
ownerPassword: "owner-secret", // required to change permissions
permissions: {
printing: "highResolution",
copying: false,
modifying: false,
},
},
},
});
| Option | Type | Description | ||
|---|---|---|---|---|
| format | "a4" | "a3" | "letter" | "legal" | Page size (default: "a4") |
| orientation | "portrait" | "landscape" | Page orientation (default: "portrait") | ||
| margin | { top?, right?, bottom?, left? } | CSS length strings, e.g. "20mm" or "0.5in" | ||
| header | { html, height? } | HTML injected above every page | ||
| footer | { html, height? } | HTML injected below every page | ||
| pageNumbers | boolean | Auto page numbers — use class="pageNumber" in header/footer HTML. System templates that benefit from page numbering (invoice, quote, contract, report, agreement, every paid tax invoice, UAE tenancy / offer letter, salary certificate, NDA) opt in by default; pass `false` here to disable on a per-request basis. | ||
| encrypt | EncryptOptions | AES-256 password protection + permission flags | ||
| validateOnly | boolean | Dry-run: validates template + data without rendering or charging a credit | ||
| bypassCache | boolean | Skip the response cache for this request (force a fresh render through the Chromium pool). Default: false. See “Response caching” below. | ||
| formFields | FormFieldSpec[] | Add fillable AcroForm widgets to the rendered PDF (text, textarea, checkbox, radio, dropdown, signature). Up to 100 per render. Each spec is positioned by page + bottom-left point coordinates (72 dpi). |
validateOnly returns { ok: true, validated: true, warnings: string[] } without rendering — zero credits consumed. Warnings are soft (unused variables, deprecated fields) and never block. Real errors (template not found, schema mismatch) still surface exactly as they would on a live render.
16
Render from HTML or a URL
In addition to named templates, you can render raw HTML strings or live URLs. Useful for server-side-rendered pages, one-off documents, or local preview flows that don't need a stored template.
ts
// Raw HTML string — no template needed const pdf = await kamy.renderHtml({ html: "
Hello world
Generated at runtime.
", options: { format: "a4" }, });// Live URL — Kamy fetches and renders the page at request time const pdf2 = await kamy.renderUrl({ url: "https://example.com/reports/2026-q1", options: { format: "letter", orientation: "landscape" }, });
console.log(pdf.url); // signed URL, same shape as template renders console.log(pdf2.url);
Both methods return an identical `RenderResponse` and accept the same `options` (format, orientation, margin, encrypt, etc.). URL renders require the target page to be publicly reachable at render time — pages behind auth will return empty or a login screen.
17
## Response caching
Identical render requests are deduped automatically. When you submit a payload whose compiled HTML and PDF options exactly match a successful render from the same account in the last 24 hours, Kamy re-signs the existing PDF and returns it instantly without re-running the browser. Quota, metering, and webhooks fire identically to a fresh render — only the Chromium compute is skipped.
The same cache also applies to every item in a `POST /v1/batch` request — repeat renders inside a batch (or across batches) are individually deduped. Bypass for an entire batch by sending the `X-Kamy-Bypass-Cache: 1` header on the batch request.
| Header / field | Direction | Description |
| ------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
| X-Kamy-Cache | response | hit when the PDF was served from cache, miss otherwise. |
| options.bypassCache | request body | Set to true to force a fresh render. The result is _not_ cached either, so subsequent identical requests will also miss. |
| X-Kamy-Bypass-Cache | request | Header equivalent of options.bypassCache. Send 1 to bypass. Either form works; the body field wins if both are present. |
**Cache scope:** per API key (so two accounts rendering the same template never share PDFs), 24-hour window since the original render, successful renders only. The cache key is a SHA-256 of the final compiled HTML (or URL) plus the resolved PDF options — so any change to the template, data, header/footer, format, encryption, etc. produces a new key and a fresh render automatically. There is no manual invalidation API; just push a new template version or change the data.
**When to bypass:** almost never. The most common reason is debugging a previously-failed render that has since been fixed upstream (e.g. a remote image URL that is now reachable). For everyday use, leave the cache on.
18
## Template versioning
Every `kamy push` saves a new immutable version. Versions are decoupled from the _published pointer_ — the version your renders actually use — so you can stage changes and cut over without affecting live traffic.
bash
After pushing changes, publish the latest draft to make it live
kamy publish
Or pin a specific version number instead of the latest
kamy publish --version 4
Roll the published pointer back to a known-good version
kamy rollback 3
Also overwrite the current draft with that version's HTML at the same time
kamy rollback 3 --restore-draft
Template IDs are UUIDs — run `kamy templates` to list them. Full version history is available at `GET /api/v1/templates/:id/versions`. Slug lookups in render calls always resolve to the currently published version.
18
## CLI reference
Install the CLI globally, save your key once, and it's available to every command. Keys are stored in `~/.kamy/config.json` and overridden by `KAMY_API_KEY` when the env var is set.
bash
npm i -g @kamydev/cli kamy config set-key kamy_pk_...
| Command | Description |
| ------------------------------------- | --------------------------------------------------------------------------------------------------- |
| kamy init <slug> | Scaffold .hbs + .css + .schema.json + .sample.json in templates/<slug>/ |
| kamy push <file.hbs> | Upsert a template by slug (idempotent, CI-safe). Accepts --css, --schema, --tag |
| kamy preview <file> | Render a local HTML file and save the PDF. --watch re-renders on every save, --open launches viewer |
| kamy render <template> | Render a named template with --data (inline JSON or @file.json), --output to save locally |
| kamy templates | List all templates (system + custom) in your account |
| kamy renders | List the 20 most recent renders with status, size, and timestamp |
| kamy publish <template-id> | Make the latest draft live. --version <n> pins a specific version |
| kamy rollback <template-id> <version> | Revert the published pointer. --restore-draft also overwrites the current draft |
| kamy uploads create <file> | Upload a local file and print its kamy://asset/<id> reference |
| kamy uploads list | List uploaded assets with size and status |
| kamy uploads delete <id> | Delete an uploaded asset |
| kamy webhooks list | List webhook endpoints |
| kamy webhooks create <url> | Create a webhook. --events for specific types (default: all). Prints secret once |
| kamy webhooks delete <id> | Delete a webhook endpoint |
| kamy webhooks test <id> | Send a test event. --event <type> (default: test.ping) |
| kamy config set-key <api-key> | Save API key to \~/.kamy/config.json |
| kamy config show | Show current config (key source, API base URL) |
| kamy doctor | Health check: Node version, CLI version, API key, connectivity, account info |
A typical local development loop:
bash
1. Scaffold a new template
kamy init my-invoice --kind invoice
→ templates/my-invoice/{my-invoice.hbs,.css,.schema.json,.sample.json}
2. Preview locally — re-renders on every save, opens in PDF viewer
kamy preview templates/my-invoice/my-invoice.hbs --open --watch
3. Push to Kamy (idempotent — safe to run in CI on every commit)
kamy push templates/my-invoice/my-invoice.hbs
--css templates/my-invoice/my-invoice.css
--schema templates/my-invoice/my-invoice.schema.json
4. Render with the sample data, save locally
kamy render my-invoice
--data @templates/my-invoice/my-invoice.sample.json
--output out.pdf
5. Verify everything is wired correctly
kamy doctor
Ready to render?
### 100 free renders every month.
Create a free accountGet your API key