DulySign.
DEVELOPER GUIDE · API V1

Plug DulySign into your systems.

Send documents for signature from your CRM, case management system or website, get told the moment they're signed, and file the signed PDF and audit trail automatically.

JSON over HTTPSSigned webhooksIncluded in every planUK hosted

Getting started.

  1. 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.
  2. Send a PDF and the signers to POST /v1/envelopes. DulySign emails each signer your branded request straight away.
  3. 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"
}
Keep keys secret. Use them from your server, never from a web page or app your clients can see. If a key leaks, revoke it in the app and make a new one. Revoking takes effect straight away.

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." } }
StatusCodeMeans
400invalid_jsonThe body isn't valid JSON.
401unauthorisedMissing, wrong or revoked key.
402subscription_inactiveThe firm's trial has ended or the subscription isn't active.
404not_foundNo such document for your firm, or no such endpoint.
409not_completed, not_openThe document isn't in the right state for that, for example asking for the signed PDF before everyone has signed.
413file_too_largePDFs can be up to 10 MB.
422invalid_request, invalid_pdfSomething in the request needs fixing. The message says what.

Send a document.

POST/v1/envelopes

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..." }
  }'
FieldRequiredWhat it is
titleYesWhat the signer sees, for example "Client agreement". Up to 160 characters.
file.base64YesThe PDF, base64 encoded. Up to 10 MB. It can't be password protected.
file.nameNoThe original file name, kept for your records.
signersYes1 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_orderNoWith more than one signer, true (the default) sends to one at a time in list order. false sends to everyone at once.
messageNoA personal note shown in the email. Up to 1,000 characters.
external_refNoYour own reference, like a case or client number. It comes back in every response and webhook, and you can search by it.
fieldsNoWhere 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 }
]
FieldWhat it is
signerThe signer's position in your signers list, starting at 0.
typesignature, initials, date (filled in when they sign), name, text or checkbox.
pagePage number, starting at 1.
x, yTop left corner of the box, 0 to 1.
w, hOptional width and height, 0 to 1. Each type has a sensible default.
requiredOptional. Everything except text is required by default.
labelOptional. Shown to the signer, for example "Agree to the terms".

Every signer needs at least one signature box.

Check a document.

GET/v1/envelopes/{id}

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 statusMeans
sentOut for signature.
completedEveryone has signed and the certificate is sealed. The signed PDF is ready.
declinedA signer declined. It won't go any further.
voidedCancelled by your firm.

Signer status moves through pending (waiting for an earlier signer), sent, viewed, verified and signed, or declined.

List documents.

GET/v1/envelopes

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.

GET/v1/envelopes/{id}/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.

GET/v1/envelopes/{id}/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.

POST/v1/envelopes/{id}/remind

Emails a fresh link to whoever needs to sign next. Any older link stops working, in case it was forwarded.

POST/v1/envelopes/{id}/cancel

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.

EventWhen
envelope.sentA document has gone out for signature.
signer.openedA signer opened their link for the first time.
signer.signedA signer has signed.
envelope.completedEveryone has signed and the certificate is sealed. Fetch the signed PDF now.
envelope.declinedA signer declined.
envelope.voidedYour 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.