Getting started.
- An admin on your DulySign account opens API in the app at app.dulysign.co.uk/api and creates a key for the system that will use it.
- Send a PDF and the signers to
POST /v1/envelopes. DulySign emails each signer your branded request straight away. - Optionally, add a webhook address on the same page so your system hears about every step.
Every request goes to:
https://dulysign.co.uk/api/v1
Documents sent through the API look exactly the same to your clients as ones sent from the app: your logo and colour, a code by text or email, and a sealed certificate of completion. They also show up in the app alongside everything else.
Keys.
Send your key in the Authorization header on every request. Keys start ds_live_. Each one belongs to your firm, so anything sent with it is sent as your firm.
curl https://dulysign.co.uk/api/v1/me \
-H "Authorization: Bearer ds_live_your_key"
{
"firm": "Your Firm Ltd",
"key_name": "Our CRM",
"can_send": true,
"plan": "yearly",
"webhook_url": "https://yourcrm.co.uk/webhooks/dulysign"
}
Errors.
Errors come back with the matching HTTP status and a plain English message you can show to whoever's using your system.
{ "error": { "code": "invalid_request", "message": "signers[0].email isn't a valid email address." } }
| Status | Code | Means |
|---|---|---|
| 400 | invalid_json | The body isn't valid JSON. |
| 401 | unauthorised | Missing, wrong or revoked key. |
| 402 | subscription_inactive | The firm's trial has ended or the subscription isn't active. |
| 404 | not_found | No such document for your firm, or no such endpoint. |
| 409 | not_completed, not_open | The document isn't in the right state for that, for example asking for the signed PDF before everyone has signed. |
| 413 | file_too_large | PDFs can be up to 10 MB. |
| 422 | invalid_request, invalid_pdf | Something in the request needs fixing. The message says what. |
Send a document.
Uploads a PDF, sends it to the signers and returns the document. Signers get your branded email straight away. If they sign in order, each one gets theirs when the person before has signed.
curl https://dulysign.co.uk/api/v1/envelopes \
-H "Authorization: Bearer ds_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"title": "Client agreement",
"message": "Hi Jane, here is the agreement we discussed.",
"external_ref": "CASE-1042",
"in_order": true,
"signers": [
{ "name": "Jane Smith", "email": "jane@example.com", "code_by": "sms", "mobile": "07700 900123" },
{ "name": "John Smith", "email": "john@example.com" }
],
"file": { "name": "agreement.pdf", "base64": "JVBERi0xLjcK..." }
}'
| Field | Required | What it is |
|---|---|---|
title | Yes | What the signer sees, for example "Client agreement". Up to 160 characters. |
file.base64 | Yes | The PDF, base64 encoded. Up to 10 MB. It can't be password protected. |
file.name | No | The original file name, kept for your records. |
signers | Yes | 1 to 10 people. Each has name, email, and optionally code_by ("email" or "sms", default email) and mobile (needed for sms; UK numbers can start 07). |
in_order | No | With more than one signer, true (the default) sends to one at a time in list order. false sends to everyone at once. |
message | No | A personal note shown in the email. Up to 1,000 characters. |
external_ref | No | Your own reference, like a case or client number. It comes back in every response and webhook, and you can search by it. |
fields | No | Where the boxes go. Leave it out and DulySign adds a signing page at the end with a signature and date box for each signer. See placing boxes. |
Response, 201 Created:
{
"id": "b22f88bc-f398-4515-a15f-db98f7f70ff3",
"title": "Client agreement",
"status": "sent",
"external_ref": "CASE-1042",
"reference": "DS-7E2F-398C",
"sent_at": "2026-11-04T13:32:46Z",
"completed_at": null,
"expires_at": "2026-11-18T13:32:46Z",
"signed_pdf_available": false,
"signers": [
{ "id": "6e7a...", "name": "Jane Smith", "email": "jane@example.com", "code_by": "sms", "order": 1, "status": "sent", "signed_at": null },
{ "id": "91c0...", "name": "John Smith", "email": "john@example.com", "code_by": "email", "order": 2, "status": "pending", "signed_at": null }
]
}
Signing links last 14 days. reference is the DulySign reference printed on the certificate.
Placing boxes yourself
To put boxes on your own document, send fields. Positions are fractions of the page measured from the top left, so they work at any page size. x: 0.1, y: 0.8 means 10% in from the left and 80% of the way down.
"fields": [
{ "signer": 0, "type": "signature", "page": 3, "x": 0.12, "y": 0.78 },
{ "signer": 0, "type": "date", "page": 3, "x": 0.60, "y": 0.80 },
{ "signer": 1, "type": "signature", "page": 3, "x": 0.12, "y": 0.88 },
{ "signer": 1, "type": "initials", "page": 1, "x": 0.85, "y": 0.94 }
]
| Field | What it is |
|---|---|
signer | The signer's position in your signers list, starting at 0. |
type | signature, initials, date (filled in when they sign), name, text or checkbox. |
page | Page number, starting at 1. |
x, y | Top left corner of the box, 0 to 1. |
w, h | Optional width and height, 0 to 1. Each type has a sensible default. |
required | Optional. Everything except text is required by default. |
label | Optional. Shown to the signer, for example "Agree to the terms". |
Every signer needs at least one signature box.
Check a document.
Returns the document in the same shape as above, with each signer's progress: opened_at, verified_at (passed their code), signed_at and declined_reason.
Document status | Means |
|---|---|
sent | Out for signature. |
completed | Everyone has signed and the certificate is sealed. The signed PDF is ready. |
declined | A signer declined. It won't go any further. |
voided | Cancelled by your firm. |
Signer status moves through pending (waiting for an earlier signer), sent, viewed, verified and signed, or declined.
List documents.
Newest first. Filter with ?status=completed or ?external_ref=CASE-1042. Use limit (up to 100, default 25). If has_more is true, pass the last created_at you got as before to get the next page.
curl "https://dulysign.co.uk/api/v1/envelopes?external_ref=CASE-1042" \
-H "Authorization: Bearer ds_live_your_key"
Signed PDF.
Once a document is completed, this returns a download link for the signed PDF, with the certificate of completion as its last pages. The link works for 10 minutes, so download it straight away and keep your own copy.
{ "url": "https://...", "expires_in": 600, "sha256": "9f3a61c2e07b...44d8c71e" }
sha256 is the fingerprint of the signed file. It matches the one on the certificate, so you can prove the copy you hold hasn't changed.
Audit trail.
Every recorded step in order, each chained to the one before it. chain_intact tells you the trail has been checked and hasn't been altered.
{
"envelope_id": "b22f88bc-...",
"chain_intact": true,
"events": [
{ "seq": 1, "event": "envelope_sent", "created_at": "2026-11-04T13:32:46Z", "actor": "Sarah Jones via API (Our CRM)" },
{ "seq": 2, "event": "sent", "created_at": "2026-11-04T13:32:47Z", "recipient_id": "6e7a..." },
{ "seq": 3, "event": "opened_link", "created_at": "2026-11-04T13:40:02Z", "recipient_id": "6e7a...", "ip": "203.0.113.24" }
]
}
Remind and cancel.
Emails a fresh link to whoever needs to sign next. Any older link stops working, in case it was forwarded.
Withdraws a document that's still out for signature. Optional body: { "reason": "Sent in error" }. The links stop working straight away.
Webhooks.
Add a webhook address on the API page in the app. DulySign then posts JSON to it whenever something happens to one of your firm's documents, whether it was sent from the API or the app.
| Event | When |
|---|---|
envelope.sent | A document has gone out for signature. |
signer.opened | A signer opened their link for the first time. |
signer.signed | A signer has signed. |
envelope.completed | Everyone has signed and the certificate is sealed. Fetch the signed PDF now. |
envelope.declined | A signer declined. |
envelope.voided | Your firm cancelled the document. |
POST https://yourcrm.co.uk/webhooks/dulysign
DulySign-Event: envelope.completed
DulySign-Delivery: 5d1c...
DulySign-Signature: t=1793799200,v1=4b9e...
{
"id": "5d1c...",
"event": "envelope.completed",
"created_at": "2026-11-04T14:02:11Z",
"envelope": { "id": "b22f88bc-...", "title": "Client agreement", "external_ref": "CASE-1042", "reference": "DS-7E2F-398C", "status": "completed" },
"signer": null
}
Reply with any 2xx status within 10 seconds. If we don't get one, we try again after 1, 5, 30, 120 and 720 minutes. You can see recent deliveries and their results on the API page. The id is the same on every retry, so you can ignore one you've already handled.
Checking it came from us
Every webhook is signed with your webhook's signing secret (starting whsec_), shown on the API page. Join the t value, a full stop and the raw request body, work out the HMAC SHA-256 with your secret, and check it matches v1. Reject anything more than 5 minutes old.
// Node.js (Express): use the raw body, not parsed JSON
import crypto from "node:crypto";
function fromDulySign(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// PHP
parse_str(str_replace(',', '&', $_SERVER['HTTP_DULYSIGN_SIGNATURE']), $p);
$expected = hash_hmac('sha256', $p['t'] . '.' . file_get_contents('php://input'), $secret);
$ok = hash_equals($expected, $p['v1']) && abs(time() - (int)$p['t']) < 300;
Help.
Questions about connecting your system, or need something the API doesn't do yet? Email hello@dulysign.co.uk. The API is included in every DulySign plan, with no extra charge per call. Fair use applies, as in our terms.