Put e-signatures inside your product

Create documents, collect signatures and record consent through one REST API. Embed the signing form in your app and get a webhook when it is done.

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"
      }
    ]
  }'

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.

  1. 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.

  2. 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 its id.

  3. Send it for signature

    Create a submission for the template. With send_email: true each signer gets an invitation email. To sign inside your app instead, pass false and use the embed_src from 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"
          }
        ]
      }'
  4. 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"}
Check your key
curl https://test.signlypdf.com/api/user \
  -H "X-Auth-Token: $SIGNLY_API_KEY"
200 OK
{
  "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.

EnvironmentBase URLKey
Productionhttps://test.signlypdf.com/apiLive key from the API settings page
Testhttps://test.signlypdf.com/apiTest 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_id on 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.

limitinteger
Records per page. Default 10, maximum 100.
afterinteger
Return records older than this id. Pass pagination.next from the previous page.
beforeinteger
Return records newer than this id, for example to check for anything created since 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"

Errors

Failed requests return a status code and a JSON body with an error message.

422 Unprocessable Content
{ "error": "email is invalid in `submitters[0]`." }
StatusMeaning
200 201Success. Creating a template returns 201; other creates return 200.
401The API key is missing, wrong or revoked.
403API access is not approved for the account, or the record belongs to a different account.
404No record with that id.
422The request was understood but invalid: a missing field, an unknown role, a file that cannot be read. The message says which.
429Too many requests. Wait for the number of seconds in Retry-After; see Rate limits.
5xxSomething 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:

X-RateLimit-Limit
Requests allowed for this key in the current 5-minute window.
X-RateLimit-Remaining
Requests left in the window.
X-RateLimit-Reset
Unix time when the window resets.

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.

Response headers
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.

GET/api/templatesList templates. Filters: q, external_id, folder, archived=true.
GET/api/templates/{id}Template with roles, fields and document links.
POST/api/templatesCreate from a PDF, image or Word file.
PUT/api/templates/{id}Rename, move, edit roles and fields, or archive with "archived": true. Note: in PUT, areas[].page starts at 0.
POST/api/templates/{id}/cloneCopy a template, optionally with a new name and external_id.
GET/api/templates/{id}/pdfDownload links for the template documents.
DELETE/api/templates/{id}Archive. Add 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 }
            ]
          }
        ]
      }
    ]
  }'

Submissions

A submission is one signing request: a template sent to one or more signers. Each signer is a submitter.

POST/api/submissionsSend a template for signature.
GET/api/submissionsList. Filters: template_id, status, q, created_at_from, completed_at_to and more.
GET/api/submissions/{id}Status, signers, values, events, documents and audit log.
GET/api/submissions/{id}/documentsDocument links, including previews while signing is in progress.
GET/api/submissions/{id}/pdfSigned PDF links once every signer has completed.
DELETE/api/submissions/{id}Archive. Add permanently=true to delete.

Body

template_idintegerrequired
submittersarrayrequired
One entry per signer, each with at least an 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.
send_emailboolean, default true
Email each signer an invitation. Set to false for embedded signing.
orderstring, default preserved
preserved invites signers one after another; random invites everyone at once.
completed_redirect_urlstring
Where to send the signer after they finish. We append ?event=completed or ?event=declined.
messageobject
Custom invitation email: subject and body (body is required).
expire_attimestamp
After this time the submission expires and the signing link stops working.
reply_to, bcc_completedemail
Reply-to address for invitations, and an address that gets a copy of the completed documents once every signer has finished. The copy is only sent when the submission's 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" }
      }
    ]
  }'
Read the result later
curl https://test.signlypdf.com/api/submissions/8861 \
  -H "X-Auth-Token: $SIGNLY_API_KEY"
Submission statusMeaning
pendingAt least one signer has not finished.
completedEvery signer has signed. Signed PDFs and the audit log are available.
declinedA 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.
expiredexpire_at passed before everyone signed.

Submitters

One signer inside a submission.

GET/api/submittersFilters: submission_id, external_id, completed_after.
GET/api/submitters/{id}One signer with values and documents.
PUT/api/submitters/{id}Fix an email, prefill values, send the invitation (only if none was sent yet) or mark as completed.

Submitter status moves through awaiting, sent, opened and ends as completed or declined. A completed submitter can no longer be changed.

Correct an email and send the invitation
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.

GET/api/clickwrapsList active clickwraps.
POST/api/clickwrapsCreate, optionally with "publish": true.
GET/api/clickwraps/{id}Settings, version number and public url.
PUT/api/clickwraps/{id}Edit the draft. Add "publish": true to release it as a new version.
POST/api/clickwraps/{id}/publishPublish the current draft as a new, immutable version.
GET/api/clickwraps/{id}/consentsConsents collected, newest first.
POST/api/clickwraps/{id}/consentsRecord a consent collected in your own UI.
GET/api/consents/{id}One consent with its certificate link.

Kinds

  • no_document: the agreement text is the content you 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
  }'

Events

A catch-up feed of completed signers, with data in the webhook format.

GET/api/events/form/completedform.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.

cURL
# 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"
POST/api/tools/mergeMerge two or more PDFs. Send files as base64; returns {"data": "<base64 PDF>"}.
POST/api/tools/verifyCheck a signed PDF (file as base64). Returns whether we issued it and its signatures.
GET/api/userThe user the API key belongs to.

Embedded signing

Show the signing form inside your own page, so signers never leave your product.

  1. Create the submission on your server with "send_email": false.
  2. Take embed_src from the signer in the response. It looks like https://test.signlypdf.com/apiemb/{slug}. If you only have a submitter from a list call, build the same URL from its slug.
  3. 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.
  4. Treat the browser event as a UI signal only. Confirm the result from the form.completed webhook or GET /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>

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.

EventWhenPayload
loadThe form has loaded in the frame.path, query
completedThe signer submitted the form.timestamp, redirect_url when one is set
declinedThe 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.

Link for one user
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:

  • ip and user_agent from the user's request. Without them the record shows your server's address.
  • name, email and external_id for 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)"
  }'

Evidence and certificates

What is stored for each consent, and how the two methods compare.

Hosted pageRecorded through the API
Agreement versionRecordedRecorded (active version)
Name, email, external idName and email entered by the user (required ones enforced); external id from the linkAs sent by your server
IP address and browserCaptured from the user's requestAs sent by your server
Checkbox answersEach box, ticked or notNot recorded
Time of agreementRecorded by SignlyPDFRecorded by SignlyPDF when the call arrives
PDF certificateYes, linked as certificate_url, and emailed to the user (when they gave an email) and to the clickwrap's creatorNo

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.

  1. Open Settings › Webhooks and add your endpoint URL. Use HTTPS in production.
  2. Choose the events you need. New endpoints start with the four form.* events.
  3. Set a secret: a header name and a random value (we recommend at least 32 characters). We use the value to sign every request.
  4. Before the first real delivery, a Test Webhook button sends a sample form.completed built from your latest completed signer. It has no event_id and 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" }
  }
}

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:

X-Signly-Signature
v1= followed by the hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with your secret value.
X-Signly-Timestamp
Unix time the request was signed. Reject requests more than five minutes old to block replays.
X-Signly-Event-Id
Stable id of the event, the same on every retry. Store it to skip duplicates.
X-Signly-Event-Type
For example 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);
});

Delivery and retries

  • Answer with any 2xx status within 30 seconds. Do the real work in a background job after you respond.
  • A 4xx or 5xx status, a timeout or a connection error counts as a failure. Redirects are not followed: a 3xx is 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.completed keeps retrying for almost 6 days.
  • Events can arrive more than once and out of order. Use event_id to 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 GET on 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.