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.
https://api.builtsign.com/v1Settings → API Keys
Get a document signed in 4 API calls.
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 KeysUpload 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.
curl -X POST https://api.builtsign.com/v1/documents/upload \
-H "X-API-Key: bs_xxxxxxxxxxxxxxxxxxxxxxxx" \
-F "file=@contract.pdf"Create a signing request with the document_id and a list of signers. Set auto_send: false to place fields first.
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
}'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
}
]
}'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. 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. 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. 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. 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. 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. 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.
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.
All v1 endpoints require an API key passed in the X-API-Key request header.
curl https://api.builtsign.com/v1/signing-requests \
-H "X-API-Key: bs_xxxxxxxxxxxxxxxxxxxxxxxx"| Scope | Permissions |
|---|---|
read | List and retrieve resources |
write | Create, update, and send resources |
admin | Reserved 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
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.
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed this minute |
X-RateLimit-Remaining | Requests left this minute |
X-RateLimit-Reset | Unix time when the minute window resets |
X-RateLimit-Daily-Limit | Requests allowed today (UTC) |
X-RateLimit-Daily-Remaining | Requests left today |
X-RateLimit-Daily-Reset | Unix time of the next midnight UTC |
Retry-After | On 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/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).
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.
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| Situation | Response |
|---|---|
| Same key, same body, first call finished | The first response, with Idempotency-Replay: true |
| Same key, different body or other endpoint | 422 idempotency_key_reused. Use a new key for every new request. |
| Same key while the first call still runs | The API waits a few seconds for its answer. Still running: 409 idempotency_key_in_use with Retry-After |
| The key store is briefly unreachable | 503 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.
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.
Upload PDFs and list previously uploaded documents.
Create, manage, and track signing requests.
Place fields on a document that signers must fill in before they can complete the signing.
Coordinates are percentage-based (0–100) relative to the page dimensions. The origin (0, 0) is the top-left corner of the page.
| field_type | Description |
|---|---|
signature | Full signature |
initials | Initials / paraaf |
date | Date (auto-filled on sign) |
text | Free text input |
checkbox | Checkbox |
select | Dropdown (provide options[]) |
Receive real-time event notifications when something happens in BuiltSign, whether in the dashboard or through the API.
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.
| Event | Trigger |
|---|---|
signing_request.created | Signing request created |
signing_request.sent | Signing request sent to signers |
signing_request.completed | All signers have signed |
signing_request.cancelled | Signing request cancelled |
signing_request.expired | Signing request expired |
signer.viewed | Signer opened the signing page |
signer.signed | Signer completed their signature |
signer.declined | Signer declined to sign |
signer.withdrawn | Signer withdrew their signature |
signer.reviewed | Signer's ID check is waiting for a manual review |
identity.approved | Signer's ID check approved |
identity.rejected | Signer's ID check rejected |
document.signed | Signed PDF generated |
signing_request.reminded | Reminder emails sent to pending signers |
document.uploaded | Document uploaded via API |
member.invited | Team member invited to the organization |
Every webhook POST includes these fields:
{
"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"
}
}| Header | Value |
|---|---|
| Content-Type | application/json |
| X-BuiltSign-Webhook-Signature | t=<unix seconds>,v1=<HMAC-SHA256 hex>, a second v1 right after a secret rotation |
| X-Webhook-Delivery-ID | delivery UUID, the same on automatic retries |
| X-Webhook-Event | event type (e.g. signer.signed) |
| X-Webhook-Timestamp | unix seconds, the same as t |
| X-Webhook-ID | webhook UUID |
| X-Webhook-Retry | attempt number on retries, resend on a redelivery |
| X-Webhook-Test | true on test events only |
| X-Webhook-Signature | deprecated: HMAC-SHA256 hex over the body only |
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.
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.Zero-dependency TypeScript client for Node.js ≥ 18. Covers every API endpoint with full type safety and built-in retry logic.
npm install @builtsign/nodeTypeScript · Node.js ≥ 18 · Zero dependencies
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);| Resource | Methods |
|---|---|
| builtsign.documents | upload(), uploadLarge(), list(), iterate(), get(), delete() |
| builtsign.signingRequests | create(), list(), iterate(), get(), events(), send(), cancel(), addFields(), download(), auditCertificate(), remind(), reactivate(), embeddedUrl() |
| builtsign.templates | list(), create(), get(), update(), delete(), send() |
| builtsign.contacts | list(), create(), delete() |
| builtsign.webhooks | list(), get(), create(), update(), delete(), regenerateSecret(), getDeliveries(), test(), redeliver(), verify() |
| builtsign.events | list(), iterate() |
| builtsign.bulk | send(), getJob() |
| builtsign.usage | get() |
| builtsign | me() |
What changed per version is in the CHANGELOG.md of the package.
Let AI assistants upload documents and send signing requests directly using the Model Context Protocol.
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.
# Claude Code
claude mcp add builtsign \
-e BUILTSIGN_API_KEY=bs_xxxxxxxxxxxxxxxxxxxxxxxx \
-e BUILTSIGN_API_URL=https://api.builtsign.com \
-- npx -y @builtsign/mcp{
"mcpServers": {
"builtsign": {
"command": "npx",
"args": ["-y", "@builtsign/mcp"],
"env": {
"BUILTSIGN_API_KEY": "bs_xxxxxxxxxxxxxxxxxxxxxxxx",
"BUILTSIGN_API_URL": "https://api.builtsign.com"
}
}
}
}| upload_document | read a local PDF and upload it |
| list_documents | |
| create_signing_request | create draft |
| create_and_send_signing_request | create and send in one step |
| send_signing_request | send an existing draft |
| get_signing_request | check status |
| list_signing_requests | with status filter and cursor pagination |
| cancel_signing_request | |
| send_reminder | |
| list_templates | |
| send_from_template | map roles to real signers |
| list_events | full audit trail with cursor pagination |
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.
Document signed
All signers complete the signing request. BuiltSign generates the final PDF and stores it securely on S3.
Webhook fires
BuiltSign immediately POSTs a JSON payload to your configured endpoint, including a 15-minute presigned URL to download the signed PDF.
Your AI processes
Your pipeline downloads the PDF and does whatever you need: extract data, classify, archive, trigger workflows. BuiltSign never sees the output.
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
BYOAI is configured per organization. You provide an HTTPS endpoint and a signing secret.
Go to Settings → Developers → Bring Your Own AI
Enter your HTTPS endpoint URL and generate a signing secret
Deploy your webhook handler. Use the signing secret to verify each request (see code below)
BuiltSign sends a POST request with this JSON body. The signed_pdf_url is a presigned S3 URL valid for 15 minutes.
{
"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.
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);
});| Header | Value |
|---|---|
| Content-Type | application/json |
| X-BuiltSign-Signature | HMAC-SHA256 hex digest of raw body |
| X-BuiltSign-Event | document.signed |
Ready to connect your AI?
Configure your endpoint in Settings. Takes under 2 minutes.
Let signers sign documents directly inside your application. No redirect to builtsign.com.
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.
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.
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.
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.
npm install @builtsign/embedimport { 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();| Event | When |
|---|---|
builtsign:ready | The page loaded and can be signed. Fields: requestId, signerId, signerStatus, requestStatus. |
builtsign:signed | The signer signed. Fields: requestId, signerId, documentName. |
builtsign:declined | The signer declined. Fields: requestId, signerId, reason. |
builtsign:error | The 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 *.
// 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.
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.
v1 stays stable. Every change is listed in the changelog.
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.
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.
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.
{
"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 }
}
}| Status | Description |
|---|---|
400 | Bad request, invalid input or constraint violation |
401 | Unauthorized, missing or invalid API key |
402 | Payment required, the plan cannot cover this send (trial over, transaction limit, SMS bundle) or not enough credits |
403 | Forbidden, insufficient scope or plan restriction |
404 | Not found, resource does not exist or belongs to another org |
409 | Conflict, action not allowed in current state |
410 | Gone, the signed PDF was removed after the retention period |
413 | Payload too large, the file is over the limit (for up to 1 GB use POST /v1/uploads) |
422 | Unprocessable, validation error (check field details) |
429 | Too many requests, rate limit exceeded |
502 | Upstream error, S3 or email provider temporarily unavailable |
503 | Briefly unavailable, nothing was done: retry after Retry-After seconds |
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.code | Description |
|---|---|
validation_error | 422 or 400: a field is missing or invalid. Field errors are in detail. |
auth_api_key_invalid | 401: the key is unknown, revoked or expired. |
auth_scope_missing | 403: the key lacks the scope (read or write). |
feature_not_in_plan | 403: the feature is not in your plan. details says which plan has it. |
sandbox_not_supported | 403: not available with a sandbox key, such as saved contacts. |
quota_exceeded | 402 or 403: a plan limit is reached. details.resource says which one (signing_requests, sms, storage, document_volume). |
insufficient_credits | 402: not enough ID check credits. |
payload_too_large | 413: the file is over the limit. details.limit holds it. |
signing_request_not_found | 404: no such request in your organisation and mode. |
document_not_found | 404: no such document in your organisation and mode. |
signing_request_wrong_status | 409: not possible in the current status, for example the certificate of an open request. |
document_not_ready | 409: the signed PDF or certificate is still being prepared. Retry after Retry-After. |
document_blocked | 422: the malware scan rejected this document. Upload a clean file. |
document_has_open_request | 409: the document is in an open request. Cancel that first. |
document_has_evidence | 409: the document holds evidence that is kept for 7 years. |
document_file_removed | 410: the file was removed after the retention period. |
template_name_exists | 409: you already have an active template with this name. |
idempotency_key_reused | 422: this Idempotency-Key was used for another request. |
idempotency_key_in_use | 409: the first call with this key still runs. Retry after Retry-After. |
rate_limit_exceeded | 429: requests-per-minute limit reached. Retry after Retry-After. |
daily_limit_exceeded | 429: daily API call limit reached. It resets at midnight UTC; do not retry before Retry-After. |
service_unavailable | 503: briefly unavailable, nothing was done. Retry after Retry-After. |