DEV Community

Nicolas Fry for TurboDocx

Posted on

E-Signature API Tutorial: Sign PDFs in Node.js, Python, PHP, Go, Java & Ruby

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:

  1. Send a PDF to one or more signers
  2. Get notified when they sign (webhook)
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
Add a /signatures/:id/download endpoint that returns the signed PDF
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Here's what the skill does, in order, so there's no magic involved:

  1. Detect: reads package.json, pyproject.toml or requirements.txt, go.mod, composer.json, pom.xml or build.gradle, or Gemfile to find your language, framework, and package manager before touching a file
  2. Ask: checks what you want (TurboSign for signatures, Deliverable for document generation, or both). Webhooks, quotes, and partner provisioning are opt-in
  3. Install: runs the right command for your package manager (npm, pnpm, yarn, bun, pip, poetry, pipenv, go get, composer, Maven, Gradle, Bundler)
  4. 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 .env instead of hardcoding it
  5. Verify: checks the files exist and compile (running tsc for 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)

  1. Create a free account at app.turbodocx.com
  2. Go to Settings → API Keys and copy your API key and Organization ID
  3. 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
Enter fullscreen mode Exit fullscreen mode

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Node.js (JavaScript)
npm install @turbodocx/sdk
Enter fullscreen mode Exit fullscreen mode

Requires Node.js 18+.

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);
Enter fullscreen mode Exit fullscreen mode

TypeScript

Types ship inside the package, so there's no @types install. Use NodeNext module resolution and esModuleInterop.

npm install @turbodocx/sdk
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode

An invalid field type (or a misspelled placement) fails at compile time instead of in production.


Python

Python 3.9+. The SDK is async-native (built on httpx), so it drops straight into FastAPI or Django async views.

pip install turbodocx-sdk
Enter fullscreen mode Exit fullscreen mode
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())
Enter fullscreen mode Exit fullscreen mode

PHP

PHP 8.1+ (enums and named parameters), works in Laravel, Symfony, or plain PHP.

composer require turbodocx/sdk
Enter fullscreen mode Exit fullscreen mode
<?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";
Enter fullscreen mode Exit fullscreen mode

Go

Go 1.21+, standard library only, context.Context on every call.

go get github.com/TurboDocx/SDK/packages/go-sdk
Enter fullscreen mode Exit fullscreen mode
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)
}
Enter fullscreen mode Exit fullscreen mode

Java

Java 11+, available on Maven Central, fluent builders.

<dependency>
    <groupId>com.turbodocx</groupId>
    <artifactId>turbodocx-sdk</artifactId>
    <version>0.9.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode
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());
    }
}
Enter fullscreen mode Exit fullscreen mode

Ruby

Ruby 2.7+. Works in Rails, Sinatra, or plain Ruby.

gem install turbodocx-sdk
Enter fullscreen mode Exit fullscreen mode

Or add 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']}"
Enter fullscreen mode Exit fullscreen mode

Method names are snake_case, but the request keys stay camelCase (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 turbosign writes for you, wired into your framework's routes (Express, FastAPI, Laravel, Spring Boot, and so on) with your keys read from .env. Install it with npx 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:

  1. Upload your PDF as a template
  2. On its Signatures tab, add each signer as a role (for example "Client" and "Countersigner")
  3. 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
  4. 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);
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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']}"
Enter fullscreen mode Exit fullscreen mode

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 turbowebhooks creates 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
Enter fullscreen mode Exit fullscreen mode

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.

Node.js (Express)

Use 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 });
});
Enter fullscreen mode Exit fullscreen mode

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 });
  },
);
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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})
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In Rails, the same code goes in a controller action: read 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
}
Enter fullscreen mode Exit fullscreen mode

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 401 otherwise.
  • Make your handler idempotent. Webhooks can be delivered more than once. Key off document_id so a retry doesn't save the file twice.
  • Return 200 quickly. Do slow work (storage, emails) on a queue, not in the request.
  • Pick your events carefully. signature.document.completed is the one that means "everyone has signed and the PDF is final". signature.document.signed is 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 get signature.document.finalization_failed instead of completed, so subscribe to that one as well and handle it. signature.document.recipient_signed fires once per signer if you want per-person progress.
  • Use signingOrder for sequential signing (signer 2 only gets the document after signer 1 signs).
  • Keep the audit trail. Save the output of getAuditTrail alongside 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 429 until 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)

Collapse
 
thomas_bieser_93b20e68588 profile image
Thomas Bieser •

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.