REST API · WEBHOOKS · v1

Build with BuiltSign

Integrate eIDAS electronic signatures directly into your own application. Upload PDFs, send signing requests, place signature fields, and receive webhook events, all via a simple REST API. The API is part of Enterprise; approved partners can test for free with a sandbox key.

Base URL
url
https://api.builtsign.com/v1

Quick Start

Get a document signed in 4 API calls.

1. Create an API key

Go to Settings → API Keys in your BuiltSign dashboard and create a key with write scope. Store it securely, it is shown only once.

Settings → API Keys

2. Upload a PDF

Upload a PDF (up to 50 MB) via multipart form data. You'll receive a document_id to use in the next step. Larger files, up to 1 GB, go in parts through POST /v1/uploads.

bash
curl -X POST https://api.builtsign.com/v1/documents/upload \
  -H "X-API-Key: bs_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -F "file=@contract.pdf"

3. Create a signing request

Create a signing request with the document_id and a list of signers. Set auto_send: false to place fields first.

bash
curl -X POST https://api.builtsign.com/v1/signing-requests \
  -H "X-API-Key: bs_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "document_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "signers": [
      { "name": "Jan de Vries", "email": "jan@example.com" }
    ],
    "message": "Graag de offerte ondertekenen.",
    "expires_in_days": 7,
    "auto_send": false
  }'

4. Add fields and send

Place signature fields using percentage-based coordinates, then send to trigger the invitation email.

curl -X POST https://api.builtsign.com/v1/signing-requests/{request_id}/fields \
  -H "X-API-Key: bs_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "fields": [
      {
        "signer_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "field_type": "signature",
        "page_number": 1,
        "x_percent": 60.0,
        "y_percent": 82.0,
        "width_percent": 30.0,
        "height_percent": 8.0
      }
    ]
  }'

Partner quickstart: a large document

Send a drawing set or handover file of hundreds of MBs for signing and store the result in your own system. Six steps, all in the sandbox.

  1. 1. Sandbox key and webhook

    Create a sandbox key (Settings → API keys, tick Sandbox). A webhook created with that key only receives sandbox events. Store the secret it returns; it is shown once.

  2. 2. Upload the PDF in parts

    uploadLarge sends the file in parts straight to storage, up to 1 GB, several parts at a time. A failed part is retried with a fresh URL.

  3. 3. Create the request and place fields

    Create the request with auto_send: false and an Idempotency-Key tied to your own record, then place the fields in percent per page.

  4. 4. Send

    Send with an Idempotency-Key, so a retry after a network error never sends twice. In the sandbox no mail or SMS goes out: open the embedded signing link yourself and sign.

  5. 5. Receive and verify the webhook

    Verify X-BuiltSign-Webhook-Signature on the raw body with webhooks.verify, answer 2xx at once and skip an event_id you already handled.

  6. 6. Fetch the signed PDF and the audit certificate

    On document.signed, fetch the signed PDF through its 15-minute link and the audit certificate as PDF. A 409 document_not_ready means: wait Retry-After seconds and try again.

The whole flow in Node

typescript
import { createServer } from "node:http";
import { writeFile } from "node:fs/promises";
import BuiltSign, { BuiltSignError } from "@builtsign/node";

const builtsign = new BuiltSign({ apiKey: process.env.BUILTSIGN_API_KEY! }); // sandbox key
if (!(await builtsign.me()).sandbox) throw new Error("Use a sandbox key");

// 1. A webhook made with a sandbox key only gets sandbox events. The secret is shown once.
const webhook = await builtsign.webhooks.create({
  name: "Docstream",
  url: "https://your-host.example.com/builtsign",
  events: ["document.signed", "signer.declined", "signing_request.expired"],
});

// 2. Up to 1 GB, in parts straight to storage
const document = await builtsign.documents.uploadLarge("drawing-set.pdf", { concurrency: 4 });

// 3. A draft, tied to your own record, then the fields (percent of the page)
const request = await builtsign.signingRequests.create(
  {
    document_id: document.document_id,
    signers: [{ name: "Jan de Vries", email: "jan@example.com", order: 1 }],
    auto_send: false,
  },
  { idempotencyKey: `docstream-${recordId}` },
);
await builtsign.signingRequests.addFields(request.id, [{
  signer_id: request.signers[0].id, field_type: "signature",
  page_number: 1, x_percent: 60, y_percent: 82, width_percent: 30, height_percent: 8,
}]);

// 4. Send. Sandbox: no mail goes out, so sign through the embedded link yourself
await builtsign.signingRequests.send(request.id, { idempotencyKey: `send-${request.id}` });
const { signers } = await builtsign.signingRequests.embeddedUrl(request.id, { allowed_origin: "http://localhost:3000" });
console.log("Sign here:", signers[0].embedded_url);

// 5. Verify every delivery on the raw body, answer 2xx at once, deduplicate on event_id
const handled = new Set<string>();
createServer(async (req, res) => {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk);
  let event;
  try {
    event = builtsign.webhooks.verify(Buffer.concat(chunks), req.headers["x-builtsign-webhook-signature"], webhook.secret);
  } catch {
    return res.writeHead(401).end();
  }
  res.writeHead(200).end();
  if (handled.has(event.event_id)) return;
  handled.add(event.event_id);
  if (event.event === "document.signed") await saveResults(event.data.signing_request_id as string);
}).listen(4567);

// 6. Signed PDF and audit certificate; 409 document_not_ready means: wait Retry-After
async function saveResults(id: string) {
  const { download_url } = await whenReady(() => builtsign.signingRequests.download(id));
  await writeFile(`${id}-signed.pdf`, new Uint8Array(await (await fetch(download_url)).arrayBuffer()));
  await writeFile(`${id}-audit-certificate.pdf`, await whenReady(() => builtsign.signingRequests.auditCertificate(id)));
}

async function whenReady<T>(call: () => Promise<T>): Promise<T> {
  for (;;) {
    try {
      return await call();
    } catch (err) {
      if (!(err instanceof BuiltSignError) || err.code !== "document_not_ready") throw err;
      await new Promise((r) => setTimeout(r, (err.retryAfter ?? 5) * 1000));
    }
  }
}

Test with a sandbox key only. The example checks GET /v1/me first and stops when the key is a live key.

Authentication

All v1 endpoints require an API key passed in the X-API-Key request header.

bash
curl https://api.builtsign.com/v1/signing-requests \
  -H "X-API-Key: bs_xxxxxxxxxxxxxxxxxxxxxxxx"

Scopes

ScopePermissions
readList and retrieve resources
writeCreate, update, and send resources
adminReserved for future use

Sandbox keys

Tick Sandbox when you create a key. Requests made with it are marked sandbox: invitations and reminders are not sent, there is no ID check or SMS and nothing counts toward your document quota. Signed documents get a watermark and have no legal value, and sandbox data is deleted after 30 days. Successful responses carry the header X-BuiltSign-Mode: sandbox, and webhook events for uploading, creating, sending and signing carry the field is_sandbox. No Enterprise plan yet? Request free sandbox access; your keys then have a default limit of 30 requests per minute and 500 per day.

OpenAPI specification

Import the specification into a code generator, or browse it interactively. For Postman there is a ready-made collection with the variables baseUrl and apiKey, and a fresh Idempotency-Key on every create and send call. openapi.json · Interactive reference · Postman collection

Rate limits

Two limits apply to every key: requests per minute and requests per day. Every response tells you where you stand.

Each key has a limit per minute: 60 by default. You can set a key's own limit, but it never goes above your organisation's limit. The daily limit (2000 calls by default) resets at midnight UTC. Sandbox keys of partners without Enterprise: 30 per minute and 500 per day.

Response headers

HeaderMeaning
X-RateLimit-LimitRequests allowed this minute
X-RateLimit-RemainingRequests left this minute
X-RateLimit-ResetUnix time when the minute window resets
X-RateLimit-Daily-LimitRequests allowed today (UTC)
X-RateLimit-Daily-RemainingRequests left today
X-RateLimit-Daily-ResetUnix time of the next midnight UTC
Retry-AfterOn 429 and 503: seconds to wait before you retry

429 and 503

Over the minute limit the API returns 429 with rate_limit_exceeded and Retry-After: wait that many seconds and send the same call again. Over the daily limit it returns 429 with daily_limit_exceeded, and Retry-After runs to midnight UTC. When the rate limiter itself is briefly unavailable you get 503 with service_unavailable and Retry-After. Nothing was done, so a retry is safe, also without an Idempotency-Key.

http
HTTP/1.1 429 Too Many Requests
Retry-After: 17
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1793577617
X-RateLimit-Daily-Limit: 2000
X-RateLimit-Daily-Remaining: 1412
X-RateLimit-Daily-Reset: 1793664000

{"error": {"code": "rate_limit_exceeded", "message": "Rate limit exceeded. Please slow down.", "request_id": "req_4e092e9aa07f4103a14f"}}

The Node SDK waits for Retry-After and retries 429 and 503 by itself (maxRetries, default 3).

Idempotency

Retry a create or send call safely: with the same Idempotency-Key the API acts only once.

Send an Idempotency-Key header (any unique string, up to 255 characters) on calls that create or send: uploads, signing requests, send, cancel, remind, templates, sending from a template and bulk send. A retry with the same key and the same body returns the first response with the header Idempotency-Replay: true, instead of acting again. Keys are kept for 24 hours per API key.

bash
curl -X POST https://api.builtsign.com/v1/signing-requests \
  -H "X-API-Key: bs_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: order-1234" \
  -H "Content-Type: application/json" \
  -d '{"document_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "signers": [{"name": "Jan de Vries", "email": "jan@example.com"}]}'

# Same call again: the first response, with
# Idempotency-Replay: true
SituationResponse
Same key, same body, first call finishedThe first response, with Idempotency-Replay: true
Same key, different body or other endpoint422 idempotency_key_reused. Use a new key for every new request.
Same key while the first call still runsThe API waits a few seconds for its answer. Still running: 409 idempotency_key_in_use with Retry-After
The key store is briefly unreachable503 service_unavailable with Retry-After. Nothing was done.

Only successful responses are kept. After an error, for example a 402 or a 422, a retry with the same key runs again, so you can fix the cause and retry.

The Node SDK adds a key to every create and send call and reuses it on its own retries. Pass idempotencyKey to tie it to your own record, for example order-1234.

Pagination

Lists take limit (up to 100) with either offset or a cursor.

Offset paging (limit and offset) is fine to show a page. The response holds items and total.

For syncing, use the cursor on GET /v1/signing-requests, GET /v1/documents and GET /v1/events: pass the next_cursor of a page as starting_after until has_more is false. Items created or removed in the meantime never make you skip or repeat one.

curl "https://api.builtsign.com/v1/documents?limit=100" \
  -H "X-API-Key: bs_xxxxxxxxxxxxxxxxxxxxxxxx"
# { "items": [...], "total": 214, "has_more": true, "next_cursor": "3fa85f64-..." }

curl "https://api.builtsign.com/v1/documents?limit=100&starting_after=3fa85f64-..." \
  -H "X-API-Key: bs_xxxxxxxxxxxxxxxxxxxxxxxx"

offset together with starting_after returns 422 validation_error, as does a starting_after that is not an item of this list. Items with the same timestamp are ordered by id, so the order stays the same between calls. On /v1/events the older after_id keeps working.

Documents

Upload PDFs and list previously uploaded documents.

Signing Requests

Create, manage, and track signing requests.

Signature Fields

Place fields on a document that signers must fill in before they can complete the signing.

Field Positioning

Coordinates are percentage-based (0–100) relative to the page dimensions. The origin (0, 0) is the top-left corner of the page.

(0, 0)(100, 0)(0, 100)(100, 100)
signature
initials

Field Types

field_typeDescription
signatureFull signature
initialsInitials / paraaf
dateDate (auto-filled on sign)
textFree text input
checkboxCheckbox
selectDropdown (provide options[])

Webhooks

Receive real-time event notifications when something happens in BuiltSign, whether in the dashboard or through the API.

Setup

Create a webhook in Settings → Webhooks, or with your API key through POST /v1/webhooks. Choose which events to subscribe to. BuiltSign will send an HTTP POST to your endpoint for each event.

Events

EventTrigger
signing_request.createdSigning request created
signing_request.sentSigning request sent to signers
signing_request.completedAll signers have signed
signing_request.cancelledSigning request cancelled
signing_request.expiredSigning request expired
signer.viewedSigner opened the signing page
signer.signedSigner completed their signature
signer.declinedSigner declined to sign
signer.withdrawnSigner withdrew their signature
signer.reviewedSigner's ID check is waiting for a manual review
identity.approvedSigner's ID check approved
identity.rejectedSigner's ID check rejected
document.signedSigned PDF generated
signing_request.remindedReminder emails sent to pending signers
document.uploadedDocument uploaded via API
member.invitedTeam member invited to the organization

Payload Format

Every webhook POST includes these fields:

json
{
  "event_id": "evt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "event": "signing_request.completed",
  "timestamp": "2026-05-21T11:00:00Z",
  "webhook_id": "wh_xxxxxxxxxxxxxxxxxxxx",
  "data": {
    "signing_request_id": "sr_xxxxxxxxxxxxxxxxxxxx",
    "completed_at": "2026-05-21T11:00:00Z",
    "all_signers": [
      { "email": "alice@example.com", "name": "Alice Smith", "signed_at": "2026-05-21T10:30:00Z" },
      { "email": "bob@example.com", "name": "Bob Jones", "signed_at": "2026-05-21T11:00:00Z" }
    ],
    "signed_pdf_s3_key": "orgs/org_xxx/signed/sr_xxx.pdf"
  }
}

Request Headers

HeaderValue
Content-Typeapplication/json
X-BuiltSign-Webhook-Signaturet=<unix seconds>,v1=<HMAC-SHA256 hex>, a second v1 right after a secret rotation
X-Webhook-Delivery-IDdelivery UUID, the same on automatic retries
X-Webhook-Eventevent type (e.g. signer.signed)
X-Webhook-Timestampunix seconds, the same as t
X-Webhook-IDwebhook UUID
X-Webhook-Retryattempt number on retries, resend on a redelivery
X-Webhook-Testtrue on test events only
X-Webhook-Signaturedeprecated: HMAC-SHA256 hex over the body only

Signature Verification

Verify every webhook with the X-BuiltSign-Webhook-Signature header. Compute HMAC-SHA256 with your webhook secret over the timestamp t, a dot and the raw body, compare it in constant time with each v1, and reject a t more than 5 minutes from your clock. Never trust the payload without this check.

import crypto from "crypto";

const TOLERANCE_SECONDS = 300;

function verifyBuiltSign(rawBody, header, secret) {
  let t;
  const signatures = [];
  for (const part of String(header ?? "").split(",")) {
    const [key, value] = part.split("=", 2);
    if (key === "t") t = Number(value);
    if (key === "v1") signatures.push(value);
  }
  if (!Number.isInteger(t) || signatures.length === 0) return false;
  // Refuse old timestamps, so a captured request cannot be replayed.
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(t + ".")
    .update(rawBody) // raw Buffer, not parsed JSON
    .digest();
  // Constant-time compare; after a secret rotation there are two v1 values.
  return signatures.some((sig) => {
    const given = Buffer.from(sig, "hex");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

// Express middleware example
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const valid = verifyBuiltSign(
    req.body,
    req.headers["x-builtsign-webhook-signature"],
    process.env.BUILTSIGN_WEBHOOK_SECRET,
  );
  if (!valid) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(req.body);
  // Retries repeat event.event_id: skip an event you already handled.
  // TODO: handle event (e.g. event.event === "document.signed")
  res.sendStatus(200);
});

Built your receiver before 22 October 2026? The X-Webhook-Signature header, an HMAC over the body only, is still sent but deprecated. It has no timestamp, so a captured request can be sent again. Switch to X-BuiltSign-Webhook-Signature; the end date will be announced in the changelog at least 90 days ahead.

Retry Behavior

A delivery succeeds on any 2xx response within 10 seconds. BuiltSign retries a failed delivery 9 times with exponential backoff and jitter: after about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then every 12 hours, over roughly 2.5 days. Retries stop when you pause the webhook. A webhook is switched off automatically when 10 events in a row fail over at least 3 days.

Duplicates

Every payload has an event_id that stays the same on every retry and redelivery, and every request has an X-Webhook-Delivery-ID. Store the event_id of the events you handled and skip one you have already seen.

Rotating the secret

After you regenerate the secret, the previous secret keeps signing for 24 hours: the header then holds a second v1. A receiver that still has the old secret keeps accepting deliveries, so you can roll out the new one without missing events. Through the API you choose the overlap with overlap_hours, from 0 to 168. The overlap is only in X-BuiltSign-Webhook-Signature: the legacy X-Webhook-Signature uses the new secret at once.

Test and redeliver

  • POST /v1/webhooks/{webhook_id}/test Sends a signed test event with "test": true and the header X-Webhook-Test.
  • POST /v1/webhooks/deliveries/{delivery_id}/redeliver Sends a stored delivery again with the same body and event_id.

Node.js SDK

Zero-dependency TypeScript client for Node.js ≥ 18. Covers every API endpoint with full type safety and built-in retry logic.

Installation

bash
npm install @builtsign/node

TypeScript · Node.js ≥ 18 · Zero dependencies

Quick start

typescript
import fs from "node:fs/promises";
import BuiltSign from "@builtsign/node";

const builtsign = new BuiltSign({ apiKey: process.env.BUILTSIGN_API_KEY! });

// 1. Upload a PDF
const { document_id } = await builtsign.documents.upload(
  await fs.readFile("contract.pdf"),
  "contract.pdf",
);

// 2. Create a signing request
const request = await builtsign.signingRequests.create({
  document_id,
  signers: [
    { name: "Jan de Vries", email: "jan@example.com", order: 1 },
    { name: "Lisa Smit",    email: "lisa@example.com", order: 2 },
  ],
  signing_order: "sequential",
  expires_in_days: 7,
  auto_send: false,
});

// 3. Place signature fields
await builtsign.signingRequests.addFields(request.id, [{
  signer_id: request.signers[0].id,
  field_type: "signature",
  page_number: 1,
  x_percent: 60,
  y_percent: 82,
  width_percent: 30,
  height_percent: 8,
}]);

// 4. Send
await builtsign.signingRequests.send(request.id);

API resources

ResourceMethods
builtsign.documentsupload(), uploadLarge(), list(), iterate(), get(), delete()
builtsign.signingRequestscreate(), list(), iterate(), get(), events(), send(), cancel(), addFields(), download(), auditCertificate(), remind(), reactivate(), embeddedUrl()
builtsign.templateslist(), create(), get(), update(), delete(), send()
builtsign.contactslist(), create(), delete()
builtsign.webhookslist(), get(), create(), update(), delete(), regenerateSecret(), getDeliveries(), test(), redeliver(), verify()
builtsign.eventslist(), iterate()
builtsign.bulksend(), getJob()
builtsign.usageget()
builtsignme()

What changed per version is in the CHANGELOG.md of the package.

MCP Server

Let AI assistants upload documents and send signing requests directly using the Model Context Protocol.

What is MCP?

The Model Context Protocol (MCP) is an open standard by Anthropic that lets AI assistants like Claude call external tools. The BuiltSign MCP server exposes 15 tools so an AI can manage your signing workflow end-to-end.

Installation

bash
# Claude Code
claude mcp add builtsign \
  -e BUILTSIGN_API_KEY=bs_xxxxxxxxxxxxxxxxxxxxxxxx \
  -e BUILTSIGN_API_URL=https://api.builtsign.com \
  -- npx -y @builtsign/mcp

Claude Desktop config

json
{
  "mcpServers": {
    "builtsign": {
      "command": "npx",
      "args": ["-y", "@builtsign/mcp"],
      "env": {
        "BUILTSIGN_API_KEY": "bs_xxxxxxxxxxxxxxxxxxxxxxxx",
        "BUILTSIGN_API_URL": "https://api.builtsign.com"
      }
    }
  }
}

Available tools

upload_documentread a local PDF and upload it
list_documents
create_signing_requestcreate draft
create_and_send_signing_requestcreate and send in one step
send_signing_requestsend an existing draft
get_signing_requestcheck status
list_signing_requestswith status filter and cursor pagination
cancel_signing_request
send_reminder
list_templates
send_from_templatemap roles to real signers
list_eventsfull audit trail with cursor pagination

Bring Your Own AI (BYOAI)

Connect your own AI pipeline to BuiltSign. After every completed signing, receive a webhook with a 15-minute download link to the signed PDF. Process it entirely on your own infrastructure.

Enterprise only

How it works

01

Document signed

All signers complete the signing request. BuiltSign generates the final PDF and stores it securely on S3.

02

Webhook fires

BuiltSign immediately POSTs a JSON payload to your configured endpoint, including a 15-minute presigned URL to download the signed PDF.

03

Your AI processes

Your pipeline downloads the PDF and does whatever you need: extract data, classify, archive, trigger workflows. BuiltSign never sees the output.

What you can build

Extract contract data (names, amounts, dates) and push to CRM or ERP

Trigger downstream workflows: onboarding, provisioning, compliance checks

Send Slack notifications with an AI-generated summary of the signed document

Feed signed contracts into a RAG pipeline for internal document search

Setup

BYOAI is configured per organization. You provide an HTTPS endpoint and a signing secret.

1

Go to Settings → Developers → Bring Your Own AI

2

Enter your HTTPS endpoint URL and generate a signing secret

3

Deploy your webhook handler. Use the signing secret to verify each request (see code below)

Open AI integration settings

Webhook payload

BuiltSign sends a POST request with this JSON body. The signed_pdf_url is a presigned S3 URL valid for 15 minutes.

json
{
  "event": "document.signed",
  "signing_request_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "document_name": "Koopcontract 2026.pdf",
  "completed_at": "2026-06-16T10:30:00Z",
  "signed_pdf_url": "https://s3.amazonaws.com/builtsign/signed/...?X-Amz-Expires=900",
  "expires_in": 900,
  "signers": [
    {
      "name": "Jan de Vries",
      "email": "jan@example.com",
      "signed_at": "2026-06-16T10:28:00Z"
    },
    {
      "name": "Lisa Smit",
      "email": "lisa@example.com",
      "signed_at": "2026-06-16T10:30:00Z"
    }
  ]
}

signed_pdf_url expires after 15 minutes. Download the PDF immediately in your handler.

Verify X-BuiltSign-Signature on every request. Reject anything that doesn't match.

Verifying the signature and downloading the PDF

Always verify the HMAC-SHA256 signature before processing the payload. Use the raw request body (not parsed JSON) for the HMAC calculation.

import crypto from "crypto";
import express from "express";

app.post("/ai-webhook", express.raw({ type: "application/json" }), async (req, res) => {
  // 1. Verify the signature
  const signature = req.headers["x-builtsign-signature"];
  const secret    = process.env.BUILTSIGN_AI_SECRET;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(req.body)          // raw Buffer: do NOT parse first
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(401).send("Invalid signature");
  }

  const payload = JSON.parse(req.body);

  // 2. Download the signed PDF (URL valid 15 minutes)
  const pdfResponse = await fetch(payload.signed_pdf_url);
  const pdfBuffer   = await pdfResponse.arrayBuffer();

  // 3. Send to your AI pipeline
  await yourAiPipeline.process({
    documentName: payload.document_name,
    pdf:          Buffer.from(pdfBuffer),
    signers:      payload.signers,
    completedAt:  payload.completed_at,
  });

  res.sendStatus(200);
});

Request headers

HeaderValue
Content-Typeapplication/json
X-BuiltSign-SignatureHMAC-SHA256 hex digest of raw body
X-BuiltSign-Eventdocument.signed

Ready to connect your AI?

Configure your endpoint in Settings. Takes under 2 minutes.

Configure now

Embedded Signing

Let signers sign documents directly inside your application. No redirect to builtsign.com.

0. Register your origin

An admin adds the origin of your app, like https://app.example.com, to the embed allowlist under Settings > Integrations (up to 20). Only those origins can frame the signing page. http://localhost works for development.

1. Generate an embedded URL (server-side)

Call embeddedUrl() (POST /v1/signing-requests/:id/embedded-url) from your backend with the allowed origin of your frontend. It returns a URL per signer that can be framed for 60 minutes. Treat it as a secret: whoever has it can sign.

typescript
const { signers } = await builtsign.signingRequests.embeddedUrl(
  request.id,
  {
    allowed_origin: "https://yourapp.com",
    // Optional: where the iframe goes after signing or declining
    return_url: "https://yourapp.com/contracts/123/done",
  },
);

// One URL per signer, valid for 60 minutes
const signerUrl = signers[0].embedded_url;

return_url is optional. After signing or declining, the iframe goes there with builtsign_event=signed or builtsign_event=declined and builtsign_request_id added. Its origin must be on your allowlist; BuiltSign checks it when the URL is made and again on every page load.

2. Mount the signing iframe (client-side)

Install @builtsign/embed and pass the URL to BuiltSignEmbed. It creates the iframe, accepts only messages from that iframe and the BuiltSign origin, and calls your callbacks.

bash
npm install @builtsign/embed
typescript
import { BuiltSignEmbed } from "@builtsign/embed";

const embed = new BuiltSignEmbed({
  container: "#signing-container",  // <div id="signing-container">
  embeddedUrl: signerUrl,

  onReady: (e) => console.log("Ready", e.requestId),
  onSigned: (e) => {
    console.log("Signed", e.requestId, e.documentName);
    embed.close();
  },
  onDeclined: (e) => {
    console.log("Declined", e.reason);
    embed.close();
  },
  onError: (e) => {
    console.error(e.code, e.message);
    if (!e.recoverable) embed.close();
  },
});

embed.open();

Events

EventWhen
builtsign:readyThe page loaded and can be signed. Fields: requestId, signerId, signerStatus, requestStatus.
builtsign:signedThe signer signed. Fields: requestId, signerId, documentName.
builtsign:declinedThe signer declined. Fields: requestId, signerId, reason.
builtsign:errorThe page cannot be signed, or an action failed. Fields: code, message, recoverable, requestId, signerId.

Error codes: invalid_link, load_failed, embed_session_invalid, signing_request_expired, signing_request_cancelled, signing_request_completed, signer_declined, sign_failed and decline_failed. With recoverable: true the signer can retry inside the iframe. The SDK adds ready_timeout when no ready arrives within 30 seconds, usually because the URL expired or your page is not the allowed origin. The page sends every message to your allowed origin only, never to *.

typescript
// Without the SDK: check origin and source yourself
window.addEventListener("message", (event) => {
  if (event.origin !== "https://builtsign.com") return;
  if (event.source !== iframe.contentWindow) return;
  if (event.data?.type === "builtsign:signed") {
    // event.data.requestId, event.data.signerId
  }
});

Confirm on your server

The events come from the browser. Before you act on a signature, confirm it with the signer.signed or document.signed webhook, or with GET /v1/signing-requests/:id.

Safari, Firefox and blocked storage

The signing page needs no cookies or local storage to sign, so it also works when the browser blocks them in third-party iframes. Do not add a sandbox attribute to the iframe. The optional ID check uses the camera; the SDK allows it on the iframe.

Versioning and changelog

v1 stays stable. Every change is listed in the changelog.

  • A change that can break a correct integration is announced at least 90 days ahead in the changelog.
  • Not breaking, and added without notice: new endpoints, new optional parameters, new fields in responses and webhook payloads, new webhook events, new error codes and new headers. Ignore what you do not know, and act on error.code rather than on the message.
  • An endpoint that is being phased out answers with a Deprecation header and a Link to the changelog, and with Sunset (RFC 8594) once the end date is set.
  • Security fixes can ship without notice. They are in the changelog too, with their date.

What a deprecated endpoint returns

http
HTTP/1.1 200 OK
Deprecation: @1793577600
Sunset: Mon, 01 Feb 2027 00:00:00 GMT
Link: <https://builtsign.com/developers/changelog>; rel="deprecation"; type="text/html",
      <https://builtsign.com/developers/changelog>; rel="sunset"; type="text/html"

No endpoint is deprecated at the moment.

Open the changelog

Enterprise

Built for enterprise teams

Need white-label branding, your own email domain, or contract terms? BuiltSign is a SaaS product, no self-installation required. Contact us to discuss enterprise onboarding, custom contracts, and advanced integrations.

  • White-label API & signing UI
  • REST API and webhooks
  • Custom email domain
  • Contractual volumes and storage
  • SLA + priority support
  • Custom rate limits & volume pricing
Contact sales →

Error Codes

Every error returns a JSON body with an error object holding code, message and request_id. The code is machine-readable; quote the request_id when you contact support.

json
{
  "error": {
    "code": "quota_exceeded",
    "message": "Not enough SMS left this period (100/100 used, 2 needed).",
    "request_id": "req_4e092e9aa07f4103a14f",
    "details": { "resource": "sms", "used": 100, "limit": 100, "needed": 2 }
  }
}
StatusDescription
400Bad request, invalid input or constraint violation
401Unauthorized, missing or invalid API key
402Payment required, the plan cannot cover this send (trial over, transaction limit, SMS bundle) or not enough credits
403Forbidden, insufficient scope or plan restriction
404Not found, resource does not exist or belongs to another org
409Conflict, action not allowed in current state
410Gone, the signed PDF was removed after the retention period
413Payload too large, the file is over the limit (for up to 1 GB use POST /v1/uploads)
422Unprocessable, validation error (check field details)
429Too many requests, rate limit exceeded
502Upstream error, S3 or email provider temporarily unavailable
503Briefly unavailable, nothing was done: retry after Retry-After seconds

Error codes

Act on error.code, not on the message. New codes can be added; treat an unknown code according to its HTTP status. Many errors also carry details, for example used and limit.

error.codeDescription
validation_error422 or 400: a field is missing or invalid. Field errors are in detail.
auth_api_key_invalid401: the key is unknown, revoked or expired.
auth_scope_missing403: the key lacks the scope (read or write).
feature_not_in_plan403: the feature is not in your plan. details says which plan has it.
sandbox_not_supported403: not available with a sandbox key, such as saved contacts.
quota_exceeded402 or 403: a plan limit is reached. details.resource says which one (signing_requests, sms, storage, document_volume).
insufficient_credits402: not enough ID check credits.
payload_too_large413: the file is over the limit. details.limit holds it.
signing_request_not_found404: no such request in your organisation and mode.
document_not_found404: no such document in your organisation and mode.
signing_request_wrong_status409: not possible in the current status, for example the certificate of an open request.
document_not_ready409: the signed PDF or certificate is still being prepared. Retry after Retry-After.
document_blocked422: the malware scan rejected this document. Upload a clean file.
document_has_open_request409: the document is in an open request. Cancel that first.
document_has_evidence409: the document holds evidence that is kept for 7 years.
document_file_removed410: the file was removed after the retention period.
template_name_exists409: you already have an active template with this name.
idempotency_key_reused422: this Idempotency-Key was used for another request.
idempotency_key_in_use409: the first call with this key still runs. Retry after Retry-After.
rate_limit_exceeded429: requests-per-minute limit reached. Retry after Retry-After.
daily_limit_exceeded429: daily API call limit reached. It resets at midnight UTC; do not retry before Retry-After.
service_unavailable503: briefly unavailable, nothing was done. Retry after Retry-After.