Almost every SaaS app eventually needs a signature: a contract, an NDA, an onboarding form, a statement of work. Building it yourself means field placement on PDFs, signer emails, ordering, tamper-proof audit trails, and a legal story you'd rather not own.
An e-signature API (you'll also see it called an electronic signature API or digital signature API) turns that into three calls:
- Send a PDF to one or more signers
- Get notified when they sign (webhook)
- Download the signed PDF
You can get there two ways:
- The shortcut: install one agent skill and let your AI coding agent do the setup (next section)
- By hand: sign PDFs from code with the SDK in Node.js, TypeScript, Python, PHP, Go, Java, and Ruby (the rest of this post)
The shortcut: a Claude Code skill (also works in Cursor and Copilot)
If you already work with an AI coding agent, you can skip the docs entirely. We publish an open-source Agent Skill that teaches your agent how to install and wire up the SDK for your project:
npx skills add TurboDocx/quickstart
The installer is the open-source skills CLI. It asks which skills you want and writes them where your agent looks for them (.claude/skills/, .cursor/skills/, .github/skills/, and so on). You need Node.js 18+ for the installer only; the skill itself targets whatever language your project uses.
It works with Claude Code, Cursor, GitHub Copilot, OpenCode, OpenAI Codex CLI, and Gemini CLI, since it follows the open Agent Skill spec instead of tying you to one vendor.
You get two skills in one install:
-
/turbodocx-sdk: e-signatures, signature webhooks, embedded signing, document generation, and quotes in JavaScript/TypeScript, Python, Go, PHP, Java, or Ruby -
/turbodocx-html-to-docx: HTML to Word documents in any Node.js project (Express, Next.js, Fastify, NestJS, or plain Node)
On Claude Code you can also install it as a plugin with claude plugin add TurboDocx/quickstart.
After that, just ask in plain English:
Add TurboSign e-signatures to my Express app
Add a /signatures/:id/download endpoint that returns the signed PDF
Or skip the questions and go straight to what you need:
/turbodocx-sdk turbosign # send for signature, status, download
/turbodocx-sdk turbowebhooks # webhook subscription and a verified handler
Here's what the skill does, in order, so there's no magic involved:
-
Detect: reads
package.json,pyproject.tomlorrequirements.txt,go.mod,composer.json,pom.xmlorbuild.gradle, orGemfileto find your language, framework, and package manager before touching a file - Ask: checks what you want (TurboSign for signatures, Deliverable for document generation, or both). Webhooks, quotes, and partner provisioning are opt-in
- Install: runs the right command for your package manager (npm, pnpm, yarn, bun, pip, poetry, pipenv, go get, composer, Maven, Gradle, Bundler)
-
Generate: writes a config file and route handlers that follow your project's patterns, wires them into your app, and reads your API key from
.envinstead of hardcoding it -
Verify: checks the files exist and compile (running
tscfor TypeScript), then lists what it created, which env vars to fill in, and a curl command to test the first endpoint
If you don't have an API key yet, grab one from the free plan at app.turbodocx.com and paste it into your .env.
Review what your agent writes. Agent-generated code is a starting point, not a finished feature. In particular, if you ask for a webhook handler, check it against the raw-body rule in Step 2 below. That's the part people (and agents) most often get subtly wrong.
The skill is MIT licensed and lives at github.com/TurboDocx/quickstart.
Prefer to do it by hand? Here's the whole flow
Pick your language, copy, run. Each block installs that language's e-signature SDK and sends a PDF for signature. It's the same SDK code the skill wires into your app, so it's worth reading once to know what you're shipping.
Setup (same for every language)
- Create a free account at app.turbodocx.com
- Go to Settings → API Keys and copy your API key and Organization ID
- Put the credentials in your environment:
TURBODOCX_API_KEY=your_api_key_here
TURBODOCX_ORG_ID=your_org_id_here
TURBODOCX_SENDER_EMAIL=you@company.com # required, used as the reply-to on signing emails
TURBODOCX_SENDER_NAME=Your Company Name
TURBODOCX_WEBHOOK_SECRET=your_webhook_secret_here # you get this in Step 2, when you create the webhook
How field placement works
You don't need pixel coordinates. Put an anchor like {signature1} in your PDF where the signature should go, then tell the API to replace it:
{
"type": "signature",
"recipientEmail": "john@example.com",
"template": {
"anchor": "{signature1}",
"placement": "replace",
"size": { "width": 100, "height": 30 }
}
}
If you regenerate the contract and the layout shifts, the field moves with the text. Same idea works for initials, dates, text boxes, and checkboxes. If editing anchor text into PDFs gets annoying, skip ahead to drag and drop with signer roles.
Step 1: Send a PDF for signature with the e-signature API
Requires Node.js 18+.Node.js (JavaScript)
npm install @turbodocx/sdk
import { TurboSign } from '@turbodocx/sdk';
import { readFileSync } from 'fs';
TurboSign.configure({
apiKey: process.env.TURBODOCX_API_KEY,
orgId: process.env.TURBODOCX_ORG_ID,
senderEmail: process.env.TURBODOCX_SENDER_EMAIL, // required
senderName: process.env.TURBODOCX_SENDER_NAME,
});
const result = await TurboSign.sendSignature({
file: readFileSync('contract.pdf'),
documentName: 'Partnership Agreement',
recipients: [
{ name: 'John Doe', email: 'john@example.com', signingOrder: 1 },
],
fields: [
{
type: 'signature',
recipientEmail: 'john@example.com',
template: {
anchor: '{signature1}',
placement: 'replace',
size: { width: 100, height: 30 },
},
},
],
});
console.log('Document ID:', result.documentId);
Types ship inside the package, so there's no An invalid field TypeScript
@types install. Use NodeNext module resolution and esModuleInterop.
npm install @turbodocx/sdk
import {
TurboSign,
type HttpClientConfig,
type Recipient,
type Field,
type SendSignatureResponse,
} from '@turbodocx/sdk';
import { readFileSync } from 'fs';
const config: HttpClientConfig = {
apiKey: process.env.TURBODOCX_API_KEY,
orgId: process.env.TURBODOCX_ORG_ID,
senderEmail: process.env.TURBODOCX_SENDER_EMAIL, // required
senderName: process.env.TURBODOCX_SENDER_NAME,
};
TurboSign.configure(config);
const recipients: Recipient[] = [
{ name: 'John Doe', email: 'john@example.com', signingOrder: 1 },
];
const fields: Field[] = [
{
type: 'signature',
recipientEmail: 'john@example.com',
template: {
anchor: '{signature1}',
placement: 'replace',
size: { width: 100, height: 30 },
},
},
];
const result: SendSignatureResponse = await TurboSign.sendSignature({
file: readFileSync('contract.pdf'),
documentName: 'Partnership Agreement',
recipients,
fields,
});
console.log('Document ID:', result.documentId);
type (or a misspelled placement) fails at compile time instead of in production.
Python 3.9+. The SDK is async-native (built on Python
httpx), so it drops straight into FastAPI or Django async views.
pip install turbodocx-sdk
import asyncio
import os
from turbodocx_sdk import TurboSign
async def main():
TurboSign.configure(
api_key=os.getenv("TURBODOCX_API_KEY"),
org_id=os.getenv("TURBODOCX_ORG_ID"),
sender_email=os.getenv("TURBODOCX_SENDER_EMAIL"), # required
sender_name=os.getenv("TURBODOCX_SENDER_NAME"),
)
with open("contract.pdf", "rb") as f:
pdf_file = f.read()
result = await TurboSign.send_signature(
file=pdf_file,
document_name="Partnership Agreement",
recipients=[
{"name": "John Doe", "email": "john@example.com", "signingOrder": 1}
],
fields=[
{
"type": "signature",
"recipientEmail": "john@example.com",
"template": {
"anchor": "{signature1}",
"placement": "replace",
"size": {"width": 100, "height": 30},
},
}
],
)
print(f"Document ID: {result['documentId']}")
asyncio.run(main())
PHP 8.1+ (enums and named parameters), works in Laravel, Symfony, or plain PHP.PHP
composer require turbodocx/sdk
<?php
use TurboDocx\TurboSign;
use TurboDocx\Config\HttpClientConfig;
use TurboDocx\Types\Recipient;
use TurboDocx\Types\Field;
use TurboDocx\Types\SignatureFieldType;
use TurboDocx\Types\TemplateConfig;
use TurboDocx\Types\FieldPlacement;
use TurboDocx\Types\Requests\SendSignatureRequest;
TurboSign::configure(new HttpClientConfig(
apiKey: getenv('TURBODOCX_API_KEY'),
orgId: getenv('TURBODOCX_ORG_ID'),
senderEmail: getenv('TURBODOCX_SENDER_EMAIL'), // required
senderName: getenv('TURBODOCX_SENDER_NAME')
));
$result = TurboSign::sendSignature(
new SendSignatureRequest(
recipients: [
new Recipient('John Doe', 'john@example.com', 1),
],
fields: [
new Field(
type: SignatureFieldType::SIGNATURE,
recipientEmail: 'john@example.com',
template: new TemplateConfig(
anchor: '{signature1}',
placement: FieldPlacement::REPLACE,
size: ['width' => 100, 'height' => 30]
)
),
],
file: file_get_contents('contract.pdf'),
documentName: 'Partnership Agreement'
)
);
echo "Document ID: {$result->documentId}\n";
Go 1.21+, standard library only, Go
context.Context on every call.
go get github.com/TurboDocx/SDK/packages/go-sdk
package main
import (
"context"
"fmt"
"log"
"os"
turbodocx "github.com/TurboDocx/SDK/packages/go-sdk"
)
func main() {
client, err := turbodocx.NewClientWithConfig(turbodocx.ClientConfig{
APIKey: os.Getenv("TURBODOCX_API_KEY"),
OrgID: os.Getenv("TURBODOCX_ORG_ID"),
SenderEmail: os.Getenv("TURBODOCX_SENDER_EMAIL"), // required
SenderName: os.Getenv("TURBODOCX_SENDER_NAME"),
})
if err != nil {
log.Fatal(err)
}
pdfFile, err := os.ReadFile("contract.pdf")
if err != nil {
log.Fatal(err)
}
result, err := client.TurboSign.SendSignature(context.Background(), &turbodocx.SendSignatureRequest{
File: pdfFile,
FileName: "contract.pdf",
DocumentName: "Partnership Agreement",
Recipients: []turbodocx.Recipient{
{Name: "John Doe", Email: "john@example.com", SigningOrder: 1},
},
Fields: []turbodocx.Field{
{
Type: "signature",
RecipientEmail: "john@example.com",
Template: &turbodocx.TemplateAnchor{
Anchor: "{signature1}",
Placement: "replace",
Size: &turbodocx.Size{Width: 100, Height: 30},
},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Document ID: %s\n", result.DocumentID)
}
Java 11+, available on Maven Central, fluent builders.Java
<dependency>
<groupId>com.turbodocx</groupId>
<artifactId>turbodocx-sdk</artifactId>
<version>0.9.0</version>
</dependency>
import com.turbodocx.TurboDocxClient;
import com.turbodocx.models.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
public class Main {
public static void main(String[] args) throws Exception {
TurboDocxClient client = new TurboDocxClient.Builder()
.apiKey(System.getenv("TURBODOCX_API_KEY"))
.orgId(System.getenv("TURBODOCX_ORG_ID"))
.senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) // required
.senderName(System.getenv("TURBODOCX_SENDER_NAME"))
.build();
byte[] pdfFile = Files.readAllBytes(Paths.get("contract.pdf"));
SendSignatureResponse result = client.turboSign().sendSignature(
new SendSignatureRequest.Builder()
.file(pdfFile)
.fileName("contract.pdf")
.documentName("Partnership Agreement")
.recipients(Arrays.asList(
new Recipient("John Doe", "john@example.com", 1)
))
.fields(Arrays.asList(
new Field.Builder()
.type("signature")
.recipientEmail("john@example.com")
.template(new Field.TemplateAnchor.Builder()
.anchor("{signature1}")
.placement("replace")
.size(new Field.Size(100, 30))
.build())
.build()
))
.build()
);
System.out.println("Document ID: " + result.getDocumentId());
}
}
Ruby 2.7+. Works in Rails, Sinatra, or plain Ruby. Or add Method names are snake_case, but the request keys stay camelCase (Ruby
gem install turbodocx-sdk
gem "turbodocx-sdk" to your Gemfile and run bundle install.
require "turbodocx_sdk"
TurboDocxSdk::TurboSign.configure(
api_key: ENV.fetch("TURBODOCX_API_KEY"),
org_id: ENV.fetch("TURBODOCX_ORG_ID"),
sender_email: ENV.fetch("TURBODOCX_SENDER_EMAIL"), # required
sender_name: ENV.fetch("TURBODOCX_SENDER_NAME")
)
result = TurboDocxSdk::TurboSign.send_signature(
"file" => "contract.pdf", # a file path or any IO
"documentName" => "Partnership Agreement",
"recipients" => [
{ "name" => "John Doe", "email" => "john@example.com", "signingOrder" => 1 }
],
"fields" => [
{
"type" => "signature",
"recipientEmail" => "john@example.com",
"template" => {
"anchor" => "{signature1}",
"placement" => "replace",
"size" => { "width" => 100, "height" => 30 }
}
}
]
)
puts "Document ID: #{result['documentId']}"
documentName, recipientEmail) to match the API.
John gets an email with a signing link. No UI to build on your side. To check progress without a webhook, poll getStatus (for example under_review, completed, voided, or expired).
Agent shortcut: this is the code
/turbodocx-sdk turbosignwrites for you, wired into your framework's routes (Express, FastAPI, Laravel, Spring Boot, and so on) with your keys read from.env. Install it withnpx skills add TurboDocx/quickstart.
Prefer to drag and drop? Use signer roles
Anchors are great when you generate the PDF yourself. They're annoying when the contract comes from a lawyer, changes often, or you just want to place the fields by hand. For that case, set the document up once in the TurboDocx app and send it from code by role.
One-time setup in the app:
- Upload your PDF as a template
- On its Signatures tab, add each signer as a role (for example "Client" and "Countersigner")
- For a role that changes every time, tick Fill in name and email when sending. For a role that's always the same person (your countersigner), give it a saved name and email
- Drag each role's fields onto the PDF and save the signature setup
Then from code, name each recipient's role. The fields you placed come along with the role, so you never pass coordinates or anchors. Role keys are listed under Use via API on the template's page.
This fits best when the same document goes out again and again with different signers. The quickstart skill already prefers this path for any recurring document: it tells you what to click in TurboDocx, then generates the role-based send code and a helper that lists the template's roles.
Node.js / TypeScript
import { TurboSign } from '@turbodocx/sdk';
// TurboSign.configure({ ... }) as in Step 1
const templateId = process.env.TEMPLATE_ID;
// See the template's roles
const { roles } = await TurboSign.getTemplateSignatureSetup(templateId);
roles.forEach((r) => console.log(`${r.order}. ${r.key} (${r.label}), ${r.fieldCount} field(s)`));
const result = await TurboSign.sendSignature({
templateId,
recipients: [
{ role: 'client', name: 'Jane Doe', email: 'jane@client.com' },
// 'countersigner' left out: the signer saved on the template is used
],
// no fields: they come with each role
});
console.log('Document ID:', result.documentId);
Python
import asyncio
import os
from turbodocx_sdk import TurboSign
async def main():
# TurboSign.configure(...) as in Step 1
template_id = os.getenv("TEMPLATE_ID")
# See the template's roles
setup = await TurboSign.get_template_signature_setup(template_id)
for role in setup["roles"]:
print(f"{role['order']}. {role['key']} ({role['label']}), {role['fieldCount']} field(s)")
result = await TurboSign.send_signature(
template_id=template_id,
recipients=[
{"role": "client", "name": "Jane Doe", "email": "jane@client.com"},
# "countersigner" left out: the signer saved on the template is used
],
# no fields: they come with each role
)
print(f"Document ID: {result['documentId']}")
asyncio.run(main())
PHP
<?php
use TurboDocx\TurboSign;
use TurboDocx\Types\Recipient;
use TurboDocx\Types\Requests\SendSignatureRequest;
// TurboSign::configure(...) as in Step 1
$templateId = getenv('TEMPLATE_ID');
// See the template's roles
$setup = TurboSign::getTemplateSignatureSetup($templateId);
foreach ($setup->roles as $role) {
echo "{$role->order}. {$role->key} ({$role->label}), {$role->fieldCount} field(s)\n";
}
$result = TurboSign::sendSignature(
new SendSignatureRequest(
recipients: [
new Recipient(name: 'Jane Doe', email: 'jane@client.com', role: 'client'),
// 'countersigner' left out: the signer saved on the template is used
],
templateId: $templateId
)
);
echo "Document ID: {$result->documentId}\n";
Go
templateID := os.Getenv("TEMPLATE_ID")
ctx := context.Background()
// See the template's roles
setup, err := client.TurboSign.GetTemplateSignatureSetup(ctx, templateID)
if err != nil {
log.Fatal(err)
}
for _, role := range setup.Roles {
fmt.Printf("%d. %s (%s), %d field(s)\n", role.Order, role.Key, role.Label, role.FieldCount)
}
result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{
TemplateID: templateID,
Recipients: []turbodocx.Recipient{
{Role: "client", Name: "Jane Doe", Email: "jane@client.com"},
// "countersigner" left out: the signer saved on the template is used
},
// no Fields: they come with each role
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Document ID: %s\n", result.DocumentID)
Java
import java.util.Collections;
String templateId = System.getenv("TEMPLATE_ID");
// See the template's roles
TemplateSignatureSetup setup = client.turboSign().getTemplateSignatureSetup(templateId);
for (TemplateSignatureRole role : setup.getRoles()) {
System.out.println(role.getOrder() + ". " + role.getKey()
+ " (" + role.getLabel() + "), " + role.getFieldCount() + " field(s)");
}
SendSignatureResponse result = client.turboSign().sendSignature(
new SendSignatureRequest.Builder()
.templateId(templateId)
.recipients(Collections.singletonList(
Recipient.withRole("client", "Jane Doe", "jane@client.com")
// "countersigner" left out: the signer saved on the template is used
))
// no fields: they come with each role
.build()
);
System.out.println("Document ID: " + result.getDocumentId());
Ruby
require "turbodocx_sdk"
# TurboDocxSdk::TurboSign.configure(...) as in Step 1
template_id = ENV.fetch("TEMPLATE_ID")
# See the template's roles
setup = TurboDocxSdk::TurboSign.get_template_signature_setup(template_id)
setup["roles"].each do |role|
puts "#{role['order']}. #{role['key']} (#{role['label']}), #{role['fieldCount']} field(s)"
end
result = TurboDocxSdk::TurboSign.send_signature(
"templateId" => template_id,
"recipients" => [
{ "role" => "client", "name" => "Jane Doe", "email" => "jane@client.com" }
# "countersigner" left out: the signer saved on the template is used
]
# no fields: they come with each role
)
puts "Document ID: #{result['documentId']}"
If you name a role the template doesn't have, or leave out a role that has no saved signer, you get a validation error that names the role and lists the valid ones. Any fields you pass in the request are placed in addition to the template's saved fields, which is handy for an extra witness signature.
Step 2: Handle the webhook (and don't break the signature check)
Polling works, but a webhook tells you the moment a document is signed. Every webhook request carries an X-TurboDocx-Signature header and an X-TurboDocx-Timestamp header so you can prove the request is really from the API and hasn't been replayed.
Agent shortcut:
/turbodocx-sdk turbowebhookscreates the subscription and a handler that verifies the signature, with the raw-body parser mounted before your JSON middleware (the mistake this section is about). Read on so you know what it's doing.
Create the webhook (one time)
Register your endpoint once, and the API returns the secret you'll verify with. This example uses the JS SDK; the other SDKs ship an equivalent TurboWebhooks module (see the TurboWebhooks CRUD example in each package's examples/ folder).
import { TurboWebhooks, WebhookEvents } from '@turbodocx/sdk';
TurboWebhooks.configure({
apiKey: process.env.TURBODOCX_API_KEY,
orgId: process.env.TURBODOCX_ORG_ID,
});
const created = await TurboWebhooks.createWebhook({
urls: ['https://your-server.example.com/webhooks/turbosign'],
events: [WebhookEvents.COMPLETED, WebhookEvents.VOIDED],
});
console.log(created.secret); // shown once: save it as TURBODOCX_WEBHOOK_SECRET
A few things to know: it needs an admin-scoped API key, the URL must be HTTPS, there's one signature webhook per organization (a second create returns a 409), and regenerateWebhookSecret() rotates the secret if it ever leaks.
Now the handler. Here's the gotcha that bites almost everyone, on every webhook provider, not just this one:
Verify against the raw request body, not the parsed JSON.
If your framework parses the body first and you re-serialize it, whitespace and key order can change, the bytes no longer match, and a perfectly valid signature fails. Every handler below reads the raw bytes first and parses afterwards.
Use Node.js (Express)
express.raw() on this route, not express.json().
import express from 'express';
import { TurboSign, verifyWebhookSignature } from '@turbodocx/sdk';
const app = express();
app.post('/webhooks/turbosign', express.raw({ type: 'application/json' }), async (req, res) => {
const valid = verifyWebhookSignature(
req.body, // raw Buffer
req.headers['x-turbodocx-signature'],
req.headers['x-turbodocx-timestamp'],
process.env.TURBODOCX_WEBHOOK_SECRET,
);
if (!valid) return res.status(401).json({ error: 'invalid signature' });
const { event, data } = JSON.parse(req.body.toString());
if (event === 'signature.document.completed') {
const blob = await TurboSign.download(data.document_id); // returns a Blob
const signedPdf = Buffer.from(await blob.arrayBuffer());
await saveToStorage(signedPdf, `signed-${data.document_id}.pdf`); // your storage helper
}
res.status(200).json({ received: true });
});
TypeScript (Express)
import express, { type Request, type Response } from 'express';
import { TurboSign, verifyWebhookSignature } from '@turbodocx/sdk';
const app = express();
app.post(
'/webhooks/turbosign',
express.raw({ type: 'application/json' }),
async (req: Request, res: Response) => {
const valid = verifyWebhookSignature(
req.body as Buffer,
req.header('x-turbodocx-signature') ?? '',
req.header('x-turbodocx-timestamp') ?? '',
process.env.TURBODOCX_WEBHOOK_SECRET ?? '',
);
if (!valid) return res.status(401).json({ error: 'invalid signature' });
const { event, data } = JSON.parse((req.body as Buffer).toString());
if (event === 'signature.document.completed') {
const blob = await TurboSign.download(data.document_id); // returns a Blob
const signedPdf = Buffer.from(await blob.arrayBuffer());
await saveToStorage(signedPdf, `signed-${data.document_id}.pdf`); // your storage helper
}
res.status(200).json({ received: true });
},
);
Python (FastAPI)
import json
import os
from fastapi import FastAPI, Request, HTTPException
from turbodocx_sdk import TurboSign, verify_webhook_signature
app = FastAPI()
# The Python SDK doesn't read env vars on its own, so configure it before download()
TurboSign.configure(
api_key=os.getenv("TURBODOCX_API_KEY"),
org_id=os.getenv("TURBODOCX_ORG_ID"),
sender_email=os.getenv("TURBODOCX_SENDER_EMAIL"),
sender_name=os.getenv("TURBODOCX_SENDER_NAME"),
)
@app.post("/webhooks/turbosign")
async def handle_webhook(request: Request):
raw_body = await request.body() # bytes, before any JSON parsing
if not verify_webhook_signature(
raw_body,
request.headers.get("x-turbodocx-signature", ""),
request.headers.get("x-turbodocx-timestamp", ""),
os.getenv("TURBODOCX_WEBHOOK_SECRET"),
):
raise HTTPException(status_code=401, detail="invalid signature")
payload = json.loads(raw_body)
event, data = payload.get("event"), payload.get("data")
if event == "signature.document.completed":
document_id = data["document_id"]
signed_pdf = await TurboSign.download(document_id)
with open(f"signed-{document_id}.pdf", "wb") as f:
f.write(signed_pdf)
return {"received": True}
PHP (Laravel)
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use TurboDocx\TurboSign;
use function TurboDocx\Utils\verifyWebhookSignature;
class WebhookController extends Controller
{
public function handle(Request $request)
{
$rawBody = $request->getContent(); // raw string, not ->all()
if (!verifyWebhookSignature(
$rawBody,
$request->header('X-TurboDocx-Signature', ''),
$request->header('X-TurboDocx-Timestamp', ''),
getenv('TURBODOCX_WEBHOOK_SECRET'),
)) {
return response()->json(['error' => 'invalid signature'], 401);
}
$payload = json_decode($rawBody, true);
$event = $payload['event'] ?? null;
$data = $payload['data'] ?? [];
if ($event === 'signature.document.completed') {
$documentId = $data['document_id'];
$signedPdf = TurboSign::download($documentId);
Storage::put("signed/{$documentId}.pdf", $signedPdf);
}
return response()->json(['received' => true]);
}
}
Go (net/http)
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
turbodocx "github.com/TurboDocx/SDK/packages/go-sdk"
)
// client is the *turbodocx.Client you created earlier.
func handleWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body) // raw bytes first
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !turbodocx.VerifyWebhookSignature(
rawBody,
r.Header.Get("X-TurboDocx-Signature"),
r.Header.Get("X-TurboDocx-Timestamp"),
os.Getenv("TURBODOCX_WEBHOOK_SECRET"),
nil,
) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var payload struct {
Event string `json:"event"`
Data struct {
DocumentID string `json:"document_id"`
} `json:"data"`
}
if err := json.Unmarshal(rawBody, &payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if payload.Event == "signature.document.completed" {
signedPdf, err := client.TurboSign.Download(r.Context(), payload.Data.DocumentID)
if err != nil {
log.Printf("download error: %v", err)
} else {
os.WriteFile(fmt.Sprintf("signed-%s.pdf", payload.Data.DocumentID), signedPdf, 0644)
}
}
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
Java (Spring Boot)
import com.fasterxml.jackson.databind.ObjectMapper;
import com.turbodocx.TurboDocxClient;
import com.turbodocx.WebhookSignatureVerifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Map;
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
private final TurboDocxClient client;
private final ObjectMapper mapper = new ObjectMapper();
public WebhookController(TurboDocxClient client) {
this.client = client;
}
@PostMapping("/turbosign")
public ResponseEntity<Map<String, Object>> handleWebhook(
@RequestBody byte[] rawBody, // raw bytes, not a mapped object
@RequestHeader("X-TurboDocx-Signature") String signature,
@RequestHeader("X-TurboDocx-Timestamp") String timestamp
) throws Exception {
if (!WebhookSignatureVerifier.verify(
rawBody, signature, timestamp, System.getenv("TURBODOCX_WEBHOOK_SECRET"))) {
return ResponseEntity.status(401).body(Map.of("error", "invalid signature"));
}
Map<String, Object> payload = mapper.readValue(rawBody, Map.class);
Map<String, Object> data = (Map<String, Object>) payload.get("data");
if ("signature.document.completed".equals(payload.get("event"))) {
String documentId = (String) data.get("document_id");
byte[] signedPdf = client.turboSign().download(documentId);
Files.write(Paths.get("signed-" + documentId + ".pdf"), signedPdf);
}
return ResponseEntity.ok(Map.of("received", true));
}
}
In Rails, the same code goes in a controller action: read Ruby (Sinatra)
require "json"
require "sinatra"
require "turbodocx_sdk"
TurboDocxSdk::TurboSign.configure(
api_key: ENV.fetch("TURBODOCX_API_KEY"),
org_id: ENV.fetch("TURBODOCX_ORG_ID"),
sender_email: ENV.fetch("TURBODOCX_SENDER_EMAIL"),
sender_name: ENV.fetch("TURBODOCX_SENDER_NAME")
)
post "/webhooks/turbosign" do
raw_body = request.body.read # raw bytes, before any JSON parsing
valid = TurboDocxSdk.verify_webhook_signature(
payload: raw_body,
signature_header: request.env["HTTP_X_TURBODOCX_SIGNATURE"].to_s,
timestamp_header: request.env["HTTP_X_TURBODOCX_TIMESTAMP"].to_s,
secret: ENV.fetch("TURBODOCX_WEBHOOK_SECRET")
)
halt 401, { error: "invalid signature" }.to_json unless valid
payload = JSON.parse(raw_body)
if payload["event"] == "signature.document.completed"
document_id = payload["data"]["document_id"]
signed_pdf = TurboDocxSdk::TurboSign.download(document_id) # PDF bytes
File.binwrite("signed-#{document_id}.pdf", signed_pdf)
end
content_type :json
{ received: true }.to_json
end
request.raw_post, and skip CSRF protection for that one route.
Why the helper compares signatures in constant time
Every handler above calls the SDK's verify helper instead of comparing strings yourself. That's deliberate, and the reason is worth knowing.
The helper recomputes the signature (HMAC-SHA256 over timestamp.rawBody) and compares it to the header. A normal == (or ===, or .equals()) stops at the first byte that differs. That means a comparison that fails early returns slightly faster than one that fails late, and in theory an attacker who can measure response times precisely enough could guess a valid signature one byte at a time.
A constant-time comparison always looks at every byte, so the response time says nothing about how close the guess was. Over the public internet this attack is hard to pull off in practice, but the fix costs one function call, so there's no reason to skip it. Each SDK's helper uses its language's built-in primitive:
| Language | Constant-time compare |
|---|---|
| Node.js | crypto.timingSafeEqual |
| Python | hmac.compare_digest |
| PHP | hash_equals |
| Go | hmac.Equal |
| Java | MessageDigest.isEqual |
| Ruby |
Rack::Utils.secure_compare (or a built-in constant-time loop without Rack) |
If you ever verify by hand, this is the shape to follow (shown in Node.js):
import { createHmac, timingSafeEqual } from 'crypto';
function verify(rawBody, signatureHeader, timestamp, secret) {
const expected =
'sha256=' +
createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signatureHeader, 'utf8');
// timingSafeEqual throws if the lengths differ, so check that first
if (a.length !== b.length) return false;
return timingSafeEqual(a, b); // never: expected === signatureHeader
}
You'd also want the timestamp check from the checklist below, which the SDK helper does for you (it rejects anything more than five minutes old by default). The SDK helpers also return false on bad input instead of throwing, so a malformed request gets a clean 401 instead of a 500.
Production checklist
-
Reject unsigned or stale webhooks. Check the signature and the timestamp header, and return
401otherwise. -
Make your handler idempotent. Webhooks can be delivered more than once. Key off
document_idso a retry doesn't save the file twice. -
Return
200quickly. Do slow work (storage, emails) on a queue, not in the request. -
Pick your events carefully.
signature.document.completedis the one that means "everyone has signed and the PDF is final".signature.document.signedis partial progress only and never fires on the last signature, so don't wait on it. If the final PDF can't be generated, you getsignature.document.finalization_failedinstead ofcompleted, so subscribe to that one as well and handle it.signature.document.recipient_signedfires once per signer if you want per-person progress. -
Use
signingOrderfor sequential signing (signer 2 only gets the document after signer 1 signs). -
Keep the audit trail. Save the output of
getAuditTrailalongside the signed PDF. It's what you'll want if a signature is ever disputed.
E-signature API pricing: is there a free tier?
Yes. These are TurboDocx's published plans at the time of writing (check the pricing page for current numbers):
- Free: $0, includes API access and 5 e-signatures a month. Enough to build and test everything in this post.
-
Pro: $20 per user per month, with 60 API signatures a month. Past the cap, the API returns
429until the next billing cycle, so handle that status in production. - Enterprise: custom API limits for high-volume or white-label use.
Whatever provider you pick, ask whether you pay per envelope or a flat monthly rate, and what happens when you hit the limit. Those two answers decide your real cost far more than the headline price.
Where to go next
- Other field types: initials, dates, text, checkboxes
- Multiple recipients and sequential signing
- Embedded signing inside your own front end (so signers never leave your app)
- White-label signing, so signing emails and pages carry your brand instead of ours
- Reminders for signers who haven't finished
Want the shortcut next time? Run npx skills add TurboDocx/quickstart and ask your agent (Claude Code, Cursor, Copilot, Codex CLI, Gemini CLI, or OpenCode) to add e-signatures. If it saves you time, a star on the quickstart repo helps other developers find it. Everything here is MIT licensed and lives in the TurboDocx SDK repo. Full docs and the API reference are at docs.turbodocx.com, and you can compare options in our e-signature API guide.
If you're using a different language or hit an edge case, drop a comment and I'll add it.
Top comments (1)
Great walkthrough, and calling out the raw body rule for webhook verification is the detail that saves people hours of debugging a signature that should have passed. Shipping the setup as an agent skill alongside the manual SDK path is a smart move, since that's increasingly how developers actually integrate APIs now.