Overview
SignlyPDF gives you four building blocks. Use one or combine them: most integrations create a submission through the API, show the signing form inside their own app, and update their records from a webhook.
Quickstart
From a new account to a signed document in four steps.
-
Request API access
Create an account and tell us what you are building. Our team enables API access for your account; your key is then on the API settings page.
-
Create a template
Build it in the editor, or upload a PDF or Word file with
POST /api/templates. A template holds the document, the signer roles and the fields each role fills in. Note itsid. -
Send it for signature
Create a submission for the template. With
send_email: trueeach signer gets an invitation email. To sign inside your app instead, passfalseand use theembed_srcfrom the response.curl -X POST https://test.signlypdf.com/api/submissions \ -H "X-Auth-Token: $SIGNLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template_id": 1042, "send_email": true, "submitters": [ { "role": "Merchant", "email": "owner@harbor-bistro.com", "name": "Lena Okafor" } ] }'import os, requests res = requests.post( "https://test.signlypdf.com/api/submissions", headers={"X-Auth-Token": os.environ["SIGNLY_API_KEY"]}, json={ "template_id": 1042, "send_email": True, "submitters": [ { "role": "Merchant", "email": "owner@harbor-bistro.com", "name": "Lena Okafor", } ], }, ) submitters = res.json() -
Get notified
Add an endpoint under Settings › Webhooks and subscribe to
form.completed. The payload includes the signer's values and links to the signed PDF and audit log. See Webhooks.
Authentication
Every request carries your API key in the X-Auth-Token header.
Each user has their own key on the API settings page, and it acts with that user's permissions inside your account. Rotating the key there revokes the old one immediately.
The API is meant to be called from your server. Successful responses do not include CORS headers, so a key placed in browser code would not work and would be exposed.
- Missing, wrong or revoked key:
401 {"error":"Not authenticated"} - Account without approved API access:
403 {"error":"Forbidden"} - A record that belongs to another account:
403 {"error":"Not authorized"}
curl https://test.signlypdf.com/api/user \
-H "X-Auth-Token: $SIGNLY_API_KEY"
{
"id": 318,
"first_name": "Marcus",
"last_name": "Reyes",
"email": "marcus@harbor-payments.com"
}
Test mode
Build against a separate sandbox without touching live documents.
Switch on Test mode at the top of the API settings page. You get a separate test account with its own API key, templates, submissions and webhook endpoints, so nothing you try there reaches real signers' records.
Both environments use the same base URL; the key decides which one you talk to. Asking for a live record with a test key returns a message that tells you which key to use, for example Submission 8861 not found using testing API key; Use production API key to access production submissions.
| Environment | Base URL | Key |
|---|---|---|
| Production | https://test.signlypdf.com/api | Live key from the API settings page |
| Test | https://test.signlypdf.com/api | Test key, shown with Test mode on |
Requests
JSON over HTTPS, with resource paths under /api. The full machine-readable description is in our OpenAPI 3.1 spec; import it into Postman, Insomnia or a code generator.
- Send request bodies as JSON with
Content-Type: application/json. Without that header a JSON body is not read as JSON. - Timestamps are ISO 8601 in UTC, for example
2026-10-08T15:50:34.648Z. - Use
external_idon templates and signers to store your own identifiers. You can filter lists by it later. - File links in responses (
documents[].url,audit_log_url) are signed and expire after 40 minutes. Download the file, or fetch the record again for fresh links.
Pagination
List endpoints return the newest records first and page with a cursor.
pagination.next from the previous page.pagination.prev.When data comes back with fewer items than limit, you have reached the end.
# First page
curl "https://test.signlypdf.com/api/submissions?limit=50" \
-H "X-Auth-Token: $SIGNLY_API_KEY"
# Next page: pass pagination.next as "after"
curl "https://test.signlypdf.com/api/submissions?limit=50&after=8812" \
-H "X-Auth-Token: $SIGNLY_API_KEY"
{
"data": [ { "id": 8861, "status": "pending" }, { "id": 8812, "status": "completed" } ],
"pagination": { "count": 2, "next": 8812, "prev": 8861 }
}
Errors
Failed requests return a status code and a JSON body with an error message.
{ "error": "email is invalid in `submitters[0]`." }
| Status | Meaning |
|---|---|
200 201 | Success. Creating a template returns 201; other creates return 200. |
401 | The API key is missing, wrong or revoked. |
403 | API access is not approved for the account, or the record belongs to a different account. |
404 | No record with that id. |
422 | The request was understood but invalid: a missing field, an unknown role, a file that cannot be read. The message says which. |
429 | Too many requests. Wait for the number of seconds in Retry-After; see Rate limits. |
5xx | Something failed on our side. Retry with backoff; the request can be repeated safely only if it was a read. |
Rate limits
Each API key gets 600 requests per 5 minutes, whichever IP address the calls come from.
Every API response to a request with a key tells you where you stand:
Over the limit the API answers 429 with Retry-After set to the seconds left until the reset. Separately, one IP address can make up to 1,200 API calls per 5 minutes across all keys. Spread bulk jobs over time and use webhooks instead of polling. If you need more, talk to us before you launch.
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 587
X-RateLimit-Reset: 1791476100
Templates
A template is a document with signer roles and fields. Every submission starts from one. Every endpoint below is also described in the OpenAPI spec.
q, external_id, folder, archived=true."archived": true. Note: in PUT, areas[].page starts at 0.name and external_id.permanently=true to delete.Creating a template
Pass one or more documents. Each file is either a public https:// URL we download, or the file content as base64. PDF, images, DOCX, DOC, ODT, RTF, XLSX and PPTX are accepted; office files are converted to PDF.
Place fields with areas in relative coordinates: x, y, w and h run from 0 to 1 across the page, and page starts at 1. A role that does not exist yet becomes a new signer role. If you leave fields out, we use the form fields already in the PDF; a PDF without form fields gives a template with no fields, and you need to add some before you can send it.
Field types: text, signature, initials, date, number, checkbox, radio, select, multiple, image, file, stamp, cells, phone.
curl -X POST https://test.signlypdf.com/api/templates \
-H "X-Auth-Token: $SIGNLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Merchant Processing Agreement",
"external_id": "mpa-v3",
"documents": [
{
"name": "agreement.pdf",
"file": "https://harbor-payments.com/mpa-v3.pdf",
"fields": [
{
"name": "Signature",
"type": "signature",
"role": "Merchant",
"areas": [
{ "page": 4, "x": 0.12, "y": 0.78, "w": 0.3, "h": 0.06 }
]
}
]
}
]
}'
import base64, os, requests
with open("agreement.pdf", "rb") as f:
pdf = base64.b64encode(f.read()).decode()
template = requests.post(
"https://test.signlypdf.com/api/templates",
headers={"X-Auth-Token": os.environ["SIGNLY_API_KEY"]},
json={
"name": "Merchant Processing Agreement",
"external_id": "mpa-v3",
"documents": [{"name": "agreement.pdf", "file": pdf}],
},
).json()
Submissions
A submission is one signing request: a template sent to one or more signers. Each signer is a submitter.
template_id, status, q, created_at_from, completed_at_to and more.permanently=true to delete.Body
email, phone or name. Match a template role with role; otherwise roles are filled in order. Also accepts values (prefill by field name), readonly_fields, external_id, metadata, completed, send_email and message.false for embedded signing.preserved invites signers one after another; random invites everyone at once.?event=completed or ?event=declined.subject and body (body is required).send_email is not false.The response is an array with one object per signer. Each has an id, a slug and an embed_src for embedded signing.
curl -X POST https://test.signlypdf.com/api/submissions \
-H "X-Auth-Token: $SIGNLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": 1042,
"send_email": false,
"completed_redirect_url": "https://harbor-payments.com/done",
"submitters": [
{
"role": "Merchant",
"email": "owner@harbor-bistro.com",
"name": "Lena Okafor",
"external_id": "merchant_5521",
"metadata": { "application_id": "app_9f2c" },
"values": { "Business name": "Harbor Bistro LLC" }
}
]
}'
[
{
"id": 20471,
"submission_id": 8861,
"uuid": "3f1c6a52-8d0e-4b7e-9a51-2f64b0c1d9e7",
"slug": "pX4ytYbVfQe2Lm",
"email": "owner@harbor-bistro.com",
"name": "Lena Okafor",
"role": "Merchant",
"status": "awaiting",
"external_id": "merchant_5521",
"metadata": { "application_id": "app_9f2c" },
"embed_src": "https://test.signlypdf.com/apiemb/pX4ytYbVfQe2Lm",
"sent_at": null,
"completed_at": null
}
]
curl https://test.signlypdf.com/api/submissions/8861 \
-H "X-Auth-Token: $SIGNLY_API_KEY"
| Submission status | Meaning |
|---|---|
pending | At least one signer has not finished. |
completed | Every signer has signed. Signed PDFs and the audit log are available. |
declined | A signer declined. The reason is in the form.declined webhook as decline_reason, and in the API as submission_events[].data.reason on the decline_form event. |
expired | expire_at passed before everyone signed. |
Submitters
One signer inside a submission.
submission_id, external_id, completed_after.Submitter status moves through awaiting, sent, opened and ends as completed or declined. A completed submitter can no longer be changed.
curl -X PUT https://test.signlypdf.com/api/submitters/20471 \
-H "X-Auth-Token: $SIGNLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "lena@harbor-bistro.com",
"send_email": true
}'
Clickwrap
Create agreements and read the consents collected for them. How consent works is covered in Clickwrap consent.
"publish": true.url."publish": true to release it as a new version.Kinds
no_document: the agreement text is thecontentyou send, as plain text. Line breaks are kept; HTML is not rendered.view_only: the signer reads a template document, then agrees.requires_signature: after agreeing, the signer signs the template document.
Set kind explicitly; templates are required for the two document kinds.
curl -X POST https://test.signlypdf.com/api/clickwraps \
-H "X-Auth-Token: $SIGNLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Merchant Terms of Service",
"kind": "view_only",
"template_id": 1042,
"button_text": "I agree",
"require_name": true,
"require_email": true,
"checkboxes": [
{ "text": "I agree to the Merchant Terms.", "required": true },
{ "text": "Email me updates.", "required": false }
],
"publish": true
}'
{
"id": 77,
"name": "Merchant Terms of Service",
"kind": "view_only",
"slug": "ToazhJ5qkCwGQx",
"published": true,
"active_version_number": 1,
"url": "https://test.signlypdf.com/c/ToazhJ5qkCwGQx"
}
Events
A catch-up feed of completed signers, with data in the webhook format.
form.completed events, newest first.Use it to backfill after an outage of your webhook endpoint. after and before are Unix timestamps of completed_at rather than ids, with the same meaning as in Pagination: before=T returns events completed after T, after=T those completed before T. Each item has event_type, timestamp (the completion time) and data in the webhook format, but no event_id.
# Events completed since 1791400000 (Unix time), newest first
curl "https://test.signlypdf.com/api/events/form/completed?before=1791400000&limit=100" \
-H "X-Auth-Token: $SIGNLY_API_KEY"
files as base64; returns {"data": "<base64 PDF>"}.file as base64). Returns whether we issued it and its signatures.Embedded signing
Show the signing form inside your own page, so signers never leave your product.
- Create the submission on your server with
"send_email": false. - Take
embed_srcfrom the signer in the response. It looks likehttps://test.signlypdf.com/apiemb/{slug}. If you only have a submitter from a list call, build the same URL from itsslug. - Render it with the
<signlypdf-embed>element, which handles resizing and turns frame messages into DOM events. A plain<iframe>pointing at the same URL works too. - Treat the browser event as a UI signal only. Confirm the result from the
form.completedwebhook orGET /api/submitters/{id}before you rely on it.
<script src="https://test.signlypdf.com/js/embed.js"></script>
<signlypdf-embed
id="signing"
src="https://test.signlypdf.com/apiemb/pX4ytYbVfQe2Lm"
width="100%"
height="auto">
</signlypdf-embed>
<script>
const form = document.getElementById("signing");
form.addEventListener("completed", (event) => {
// event.detail.redirect_url is set when you passed completed_redirect_url
window.location.href = event.detail.redirect_url || "/onboarding/done";
});
form.addEventListener("declined", () => {
window.location.href = "/onboarding/declined";
});
</script>
// Plain iframe, no script tag. Listen for messages from the signing frame.
window.addEventListener("message", (event) => {
if (event.origin !== "https://test.signlypdf.com") return;
if (event.data?.source !== "signlypdf") return;
switch (event.data.type) {
case "resize": iframe.style.height = `${event.data.payload.height}px`; break;
case "completed": showSuccess(); break;
case "declined": showDeclined(); break;
}
});
Only /apiemb/ links can be framed. Regular signing links (/s/), public template links (/d/) and clickwrap pages (/c/) refuse to load inside another site's iframe. The embed URL is a credential for that signer: generate it per signer on your server and do not share it.
Frame events
<signlypdf-embed> dispatches these as bubbling DOM events with the payload in event.detail.
| Event | When | Payload |
|---|---|---|
load | The form has loaded in the frame. | path, query |
completed | The signer submitted the form. | timestamp, redirect_url when one is set |
declined | The signer declined to sign. | timestamp, redirect_url when one is set |
The frame never navigates away on its own. When a redirect_url arrives, redirecting the page is up to you.
With a plain iframe, listen for message events. Accept only messages whose event.origin is https://test.signlypdf.com and whose data.source is "signlypdf"; data.type is one of loaded, resize, completed or declined. The same event can arrive more than once, so make your handler idempotent.
Clickwrap consent
A clickwrap is an agreement accepted with a click: your terms, optional required checkboxes, and an "I agree" button. There are two ways to collect consent.
Hosted page (recommended)
Send the user to the clickwrap's url. The page shows the current version, enforces the required name, email and checkboxes on our server, and records the user's own IP address and browser. Each consent gets a PDF certificate.
Prefill name and email (used when the clickwrap asks for them), and pass external_id to link the consent to the user in your system. After agreeing, requires_signature clickwraps continue straight to the signing form.
https://test.signlypdf.com/c/ToazhJ5qkCwGQx
?external_id=merchant_5521
&name=Lena%20Okafor
&email=owner%40harbor-bistro.com
Your own UI, recorded through the API
If the checkbox lives in your app, record the consent with POST /api/clickwraps/{id}/consents once the user agrees. We store what you send, so send the user's details, not your server's:
ipanduser_agentfrom the user's request. Without them the record shows your server's address.name,emailandexternal_idfor the user.
The clickwrap must be published. Your app is responsible for showing the agreement text of the active version and for enforcing any required checkboxes.
curl -X POST https://test.signlypdf.com/api/clickwraps/77/consents \
-H "X-Auth-Token: $SIGNLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Lena Okafor",
"email": "owner@harbor-bistro.com",
"external_id": "merchant_5521",
"ip": "203.0.113.24",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6)"
}'
{
"id": 3190,
"clickwrap_id": 77,
"clickwrap_version_number": 1,
"name": "Lena Okafor",
"email": "owner@harbor-bistro.com",
"external_id": "merchant_5521",
"ip": "203.0.113.24",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6)",
"agreed_at": "2026-10-08T15:50:34.648Z",
"certificate_url": null
}
Evidence and certificates
What is stored for each consent, and how the two methods compare.
| Hosted page | Recorded through the API | |
|---|---|---|
| Agreement version | Recorded | Recorded (active version) |
| Name, email, external id | Name and email entered by the user (required ones enforced); external id from the link | As sent by your server |
| IP address and browser | Captured from the user's request | As sent by your server |
| Checkbox answers | Each box, ticked or not | Not recorded |
| Time of agreement | Recorded by SignlyPDF | Recorded by SignlyPDF when the call arrives |
| PDF certificate | Yes, linked as certificate_url, and emailed to the user (when they gave an email) and to the clickwrap's creator | No |
If consent may be used as evidence in a dispute, prefer the hosted page: the agreement is shown and the user's action is captured by SignlyPDF directly, not reported by your server. Published versions never change, so you can always show exactly what a user agreed to.
Webhooks
We send an HTTPS POST to your endpoint when something happens to a document.
- Open Settings › Webhooks and add your endpoint URL. Use HTTPS in production.
- Choose the events you need. New endpoints start with the four
form.*events. - Set a secret: a header name and a random value (we recommend at least 32 characters). We use the value to sign every request.
- Before the first real delivery, a Test Webhook button sends a sample
form.completedbuilt from your latest completed signer. It has noevent_idand does not appear in the delivery log.
Every real delivery is listed under the endpoint with its status and response code, and can be resent from there. Test mode has its own endpoints.
{
"event_id": "0b4f3d6e-6a0c-4f7a-9d2b-1c55e8a07c3f",
"event_type": "form.completed",
"timestamp": "2026-10-08T15:50:34.648Z",
"data": {
"id": 20471,
"submission_id": 8861,
"email": "owner@harbor-bistro.com",
"name": "Lena Okafor",
"role": "Merchant",
"status": "completed",
"external_id": "merchant_5521",
"metadata": { "application_id": "app_9f2c" },
"ip": "203.0.113.24",
"completed_at": "2026-10-08T15:50:31.020Z",
"values": [
{ "field": "Business name", "value": "Harbor Bistro LLC" }
],
"documents": [{ "name": "agreement", "url": "https://test.signlypdf.com/file/..." }],
"audit_log_url": "https://test.signlypdf.com/file/...",
"submission": { "id": 8861, "status": "completed" }
}
}
POST /webhooks/signly HTTP/1.1
Content-Type: application/json
User-Agent: SignlyPDF.com Webhook
X-Signly-Event-Id: 0b4f3d6e-6a0c-4f7a-9d2b-1c55e8a07c3f
X-Signly-Event-Type: form.completed
X-Signly-Timestamp: 1791475834
X-Signly-Signature: v1=8c1f0a…e94d
X-Webhook-Secret: ••••••••
Events
Every delivery has the same envelope: event_id, event_type, timestamp and data.
Signer events, data is the submitter
form.viewedThe signer opened the form. Sent on every visit.form.startedThe signer saved their first field.form.completedThe signer finished. Includes values, signed documents and audit log.form.declinedThe signer declined, with decline_reason.Document events, data is the submission or template
submission.createdA submission was created.submission.completedEvery signer has completed.submission.expiredIt expired before everyone signed.submission.archivedIt was archived. data holds id, slug and archived_at.template.createdA template was created or cloned.template.updatedA template was changed.Signer payloads are close to GET /api/submitters/{id}: the slug is in link_slug, there is no uuid or submission_events, and they add decline_reason, signer_order, total_signers, audit_log_url, the parent submission with its status, and the signer's ip and ua when they signed in the browser. Submission payloads match GET /api/submissions/{id}. Your external_id and metadata come back unchanged, so you can match events to your own records.
Signatures
Check that a request came from SignlyPDF before you act on it.
When the endpoint has a secret, each request carries:
v1= followed by the hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with your secret value.form.completed, so you can route before parsing the body.The secret header itself (for example X-Webhook-Secret: <value>) is also sent, for receivers that only compare a shared value. It carries the same value that keys the signature, so treat it as sensitive. Checking the signature adds what the header alone cannot: proof that the body was not changed, and protection against replays through the signed timestamp.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SIGNLY_WEBHOOK_SECRET; // the value you saved as the webhook secret
// Verify against the raw body, before JSON parsing changes it.
app.post("/webhooks/signly", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Signly-Timestamp");
const signature = req.get("X-Signly-Signature") || "";
const expected = "v1=" + crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${req.body}`)
.digest("hex");
const valid = Buffer.byteLength(signature) === Buffer.byteLength(expected) &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
if (!valid || !fresh) return res.sendStatus(401);
const event = JSON.parse(req.body);
// Deduplicate on event.event_id, then hand off to a queue.
res.sendStatus(200);
});
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["SIGNLY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/signly")
def signly_webhook():
timestamp = request.headers.get("X-Signly-Timestamp", "")
signature = request.headers.get("X-Signly-Signature", "")
body = request.get_data() # raw bytes
expected = "v1=" + hmac.new(
SECRET, timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
if abs(time.time() - int(timestamp)) > 300:
abort(401)
event = request.get_json()
# Deduplicate on event["event_id"], then process asynchronously.
return "", 200
# Rails controller
def signly
body = request.raw_post
timestamp = request.headers["X-Signly-Timestamp"].to_s
signature = request.headers["X-Signly-Signature"].to_s
expected = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("SIGNLY_WEBHOOK_SECRET"), "#{timestamp}.#{body}")
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
return head :unauthorized if (Time.now.to_i - timestamp.to_i).abs > 300
SignlyEventJob.perform_later(JSON.parse(body))
head :ok
end
Delivery and retries
- Answer with any
2xxstatus within 30 seconds. Do the real work in a background job after you respond. - A
4xxor5xxstatus, a timeout or a connection error counts as a failure. Redirects are not followed: a3xxis recorded as delivered and not retried. - Failed deliveries are retried with exponential backoff: 1, 2, 4, 8 minutes and so on, for about 34 hours.
form.completedkeeps retrying for almost 6 days. - Events can arrive more than once and out of order. Use
event_idto deduplicate and the record's own timestamps to order them. - Download documents from the links in the payload right away: they expire after 40 minutes. A later
GETon the submission returns fresh links. - If your endpoint was down for longer than the retry window, catch up with the events feed.
Ready to integrate?
Create an account and request API access. We review requests quickly and can help you plan your integration.