API documentation

Send contracts for signature from your own software. Create a document, place the fields, and get told when everyone has signed.

BASEhttps://app.getsigning.co.uk/api/v1

Overview

The API does one job well: it takes a file and a list of people, sends it out for signature, and hands you back a signed PDF with its audit trail. There is one call to create and send, and a webhook to tell you when it is done. You should not need to poll for anything.

Everything is JSON over HTTPS, including errors. Every response is scoped to the account the key belongs to, so a document created by one account is invisible to every other.

Sending a document costs one credit

One credit covers one document sent to as many signers as it needs, and costs £1.19 including VAT. Credits are bought in your account and never expire. A call that would take you below zero returns 402 no_credits rather than sending something you have not paid for.

Authentication

Create a key at Settings, API. Send it as the HTTP Basic username, with an empty password. Every HTTP client already supports this, so there is nothing to implement.

curl https://app.getsigning.co.uk/api/v1 \
  -u gsk_your_key_here:

A bearer token works too, if that suits your client better:

curl https://app.getsigning.co.uk/api/v1 \
  -H "Authorization: Bearer gsk_your_key_here"
GET/api/v1

A successful call confirms the key and reports your balance:

{
  "api_version": "1",
  "account": {
    "name": "Barker Roofing Ltd",
    "credits_remaining": 47,
    "can_send": true
  },
  "key": { "name": "Website integration", "prefix": "gsk_a1b2c3d4" }
}
Your key is shown once

We store only a hash of it, so we cannot show it to you again or recover it if you lose it. Put it somewhere safe, keep it out of version control, and revoke it if it ever leaks. Revoking takes effect on the next request.

Quickstart

This sends a one-page agreement to a client and asks for a signature and a date. It is the whole integration.

# base64 the file first
B64=$(base64 -w0 contract.pdf)

curl https://app.getsigning.co.uk/api/v1/documents \
  -u gsk_your_key_here: \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Roofing works - 14 Mill Lane",
    "external_ref": "JOB-1042",
    "filename": "contract.pdf",
    "file_base64": "'$B64'",
    "parties": [
      { "key": "client", "first_name": "Sarah", "last_name": "Whitfield",
        "email": "sarah@acme.co.uk" }
    ],
    "fields": [
      { "party": "client", "type": "signature",
        "page": 1, "x": 0.10, "y": 0.70, "w": 0.25, "h": 0.05 },
      { "party": "client", "type": "date",
        "page": 1, "x": 0.10, "y": 0.78, "w": 0.18, "h": 0.03 }
    ]
  }'

Rate limits

600 requests an hour per key. Creating documents is limited separately to 120 an hour. Going over either returns 429; wait and try again. If you need more, get in touch, we are not precious about it.

Limits are counted per key, not per IP address, so one customer cannot exhaust another's allowance and a leaked key still hits a ceiling.

Send a document

POST/api/v1/documents

Creates the document, places the fields and sends it, in one call. If anything in the request is wrong, nothing is created at all.

Body

FieldDescription
titleREQUIRED Shown in the email subject and in your dashboard.
file_base64REQUIRED The document, base64 encoded. PDF, Word, ODT, spreadsheets and images are accepted; anything that is not already a PDF is converted. Maximum 26 MB.
partiesREQUIRED Array, 1 to 25. See below.
fieldsREQUIRED Array. Every signer needs at least one, or they receive a document they cannot action.
filenameOPTIONAL Used to work out the format. Defaults to document.pdf.
external_refOPTIONAL Your own identifier. Returned on every response and every webhook, so you never need to store a mapping.
referenceOPTIONAL A reference shown to you in the dashboard.
messageOPTIONAL A short note included in the invitation email.
signing_orderOPTIONAL parallel (default) sends to everyone at once. sequential invites each party only once the previous one has signed.
expires_in_daysOPTIONAL 1 to 365. Links stop working afterwards.
sendOPTIONAL false creates it as a draft without sending or consuming a credit.

Parties

FieldDescription
first_nameREQUIRED
last_nameREQUIRED
emailREQUIREDWhere their private signing link goes.
keyOPTIONAL A name you choose, used by fields.party to say who fills what. Without one, the array index is used.
companyOPTIONALAppears on the signature certificate.
roleOPTIONALFree text, for example Client or Witness.
typeOPTIONAL signer (default), approver, or viewer for someone who only needs to see it.
receives_copyOPTIONAL Every party (subject to send_email below) gets the completed PDF by email regardless of this field. receives_copy: true only changes which template they get - the one addressed to the document's owner rather than a signer. It does not suppress or add the completion email itself.
send_emailOPTIONAL Defaults to true. Set false if you want to send your own invitation instead of ours - we still mint a working sign_url and return it to you, we just don't email this party ourselves. This also covers reminders and the completed-PDF email: a send_email: false party gets no email from GetSigning at any point, so you're responsible for all of their communication. Sticky: it stays in force for this party for the life of the document, including a later turn in sequential signing.

Response 201

{
  "document": {
    "id": 42,
    "uuid": "b3dab98b-6787-4570-b976-aa5f8fe6cc7c",
    "title": "Roofing works - 14 Mill Lane",
    "reference": null,
    "external_ref": "JOB-1042",
    "status": "sent",
    "created_at": "2026-08-07T13:41:08+00:00",
    "sent_at": "2026-08-07T13:41:09+00:00",
    "completed_at": null,
    "pages": 3,
    "signing_order": "parallel",
    "expires_at": "2026-09-06T13:41:08+00:00",
    "parties": [
      {
        "id": 88,
        "first_name": "Sarah",
        "last_name": "Whitfield",
        "email": "sarah@acme.co.uk",
        "type": "signer",
        "status": "sent",
        "opened": 0,
        "signed_at": null,
        "sign_url": "https://app.getsigning.co.uk/s/4b6c55ea..."
      }
    ],
    "download_url": null,
    "sha256": {
      "original": "e5594c1a64181690...",
      "signed": null
    }
  }
}
sign_url is shown once

It only appears in the response of the call that just minted it - this create call, or a call to send a draft later. A later GET on the same document will not include it. Nothing stores the plaintext link server-side, the same as an API key, so there is nothing to hand back after the fact. If you need it again, mint a fresh one via the endpoint below.

Sending a document created as a draft

POST/api/v1/documents/{id}/send

Sends a document that was created with "send": false. Body is optional. To override send_email for one or more parties at send time rather than at creation, pass:

{
  "parties": [
    { "id": 88, "send_email": false }
  ]
}

Response shape is the same as creating a document.

Each call re-mints and cancels the previous link

Calling this endpoint for a party who already has a link always mints a fresh sign_url and immediately invalidates the one before it - including a link already sitting in an earlier invitation or reminder email. That's deliberate: whatever you were last given is the only one that works. Don't call this endpoint speculatively for a party who doesn't need a new link yet.

Fetching a later signer's link in sequential signing

In sequential order, only the first signer's sign_url exists at send time - the next party's link isn't minted until it's actually their turn, same as it never was emailed to them before then. Call this same endpoint again once you'd expect them to be current (for example, after a document.signed webhook), with no body, and it returns their fresh sign_url - provided that party is send_email: false, either set at creation or in this same call. A party we are emailing ourselves refuses with 409 cannot_resend: re-minting their link here would silently break the one already in their inbox.

Placing fields

Coordinates are fractions of the page, measured from the top left. x: 0.1, y: 0.7 means a tenth of the way across and seven tenths of the way down. w and h are the width and height as fractions too.

This is deliberate: fractions do not care whether the page is A4, Letter or something unusual, and they survive any zoom or DPI. You never have to know the page size in points.

FieldDescription
partyREQUIRED The key of the party who fills this in.
typeREQUIRED See the table below.
pageREQUIRED1 based.
x, yREQUIRED 0 to 1, from the top left corner.
w, hREQUIRED 0 to 1. A signature is usually around 0.25 by 0.05.
requiredOPTIONAL Defaults to true. A required field must be completed before the signer can finish.
labelOPTIONAL Shown to the signer as a prompt.
groupOPTIONAL For radio only. See choice groups.
font_sizeOPTIONAL In points. Defaults to 10.
alignOPTIONAL left, center or right.
date_formatOPTIONAL For date. One of d/m/Y, j F Y, d M Y, Y-m-d.
Finding the right coordinates

The quickest way is to upload the document once in the web editor, drag the fields where you want them, and read the numbers off. After that, reuse the same coordinates for every document built from that template.

Field types

TypeWhat the signer does
signatureDraws with a finger or mouse, or types their name. Drawn signatures are captured as strokes and redrawn as vectors, so they stay sharp.
initialsAs above, smaller. Useful for initialling each page.
datePicks a date. Defaults to the day they sign.
textTypes free text.
checkboxTicks a box. Independent of any other box.
radioChooses one option from a group. See below.
fullnameFilled automatically from the party record. The signer cannot change it.
emailFilled automatically. Not editable.
companyFilled automatically. Not editable.
titleThe signer types their job title.

Choice groups

A radio field is a single button. Give several buttons the same group value and the signer can choose only one of them. Place each button wherever the option is printed on the page.

"fields": [
  { "party": "client", "type": "radio", "group": "Selected tier",
    "page": 1, "x": 0.08, "y": 0.42, "w": 0.02, "h": 0.02 },

  { "party": "client", "type": "radio", "group": "Selected tier",
    "page": 1, "x": 0.08, "y": 0.47, "w": 0.02, "h": 0.02 },

  { "party": "client", "type": "radio", "group": "Selected tier",
    "page": 1, "x": 0.08, "y": 0.52, "w": 0.02, "h": 0.02 }
]

Mark the group required and the signer must choose one of them before they can finish. The chosen option is marked with a filled disc in the finished PDF; the others are left untouched, so a printed contract's own empty boxes stay clean.

Get a document

GET/api/v1/documents/{id}

Returns the same shape as the create response, with each party's current status.

curl https://app.getsigning.co.uk/api/v1/documents/42 \
  -u gsk_your_key_here:

Party status is one of pending, sent, opened, signed, declined or bounced. The opened count tells you whether they have actually looked at it, which is useful before chasing.

List documents

GET/api/v1/documents
QueryDescription
statusFilter by status.
limit1 to 100. Defaults to 25.
offsetFor paging. Defaults to 0.
curl "https://app.getsigning.co.uk/api/v1/documents?status=sent&limit=50" \
  -u gsk_your_key_here:

Download the signed PDF

GET/api/v1/documents/{id}/download

Returns the PDF itself, not JSON. The file contains the original document with every signature applied, plus the signature certificate recording who signed, when, from what IP address and on what device.

Returns 409 not_ready until every party has signed. Use the webhook rather than polling for it.

curl https://app.getsigning.co.uk/api/v1/documents/42/download \
  -u gsk_your_key_here: \
  -o signed.pdf

Send a reminder

POST/api/v1/documents/{id}/remind

Emails everyone who has not yet signed. People who have already signed or declined are skipped, and so is anyone with send_email: false - you're sending their invitations and reminders yourself, so we leave them alone. Returns the number contacted.

curl -X POST https://app.getsigning.co.uk/api/v1/documents/42/remind \
  -u gsk_your_key_here:

# { "reminded": 2 }

Withdraw a document

POST/api/v1/documents/{id}/void

Stops the document. Every signing link dies immediately. The document and its audit trail are kept, so the record of what happened survives. A completed document cannot be withdrawn.

curl -X POST https://app.getsigning.co.uk/api/v1/documents/42/void \
  -u gsk_your_key_here: \
  -H "Content-Type: application/json" \
  -d '{"reason": "Superseded by a revised quote"}'

Webhooks

Add an endpoint at Settings, API and we will POST JSON to it when something happens. This is how you find out a document is finished. Polling works, but it is wasteful and slow.

Your endpoint must be https and publicly reachable. Private and loopback addresses are refused, so that a webhook URL can never be used to make our servers fetch something inside a private network.

Events

EventFires when
document.completedEvery party has signed. Carries the download URL and the signed file's hash.
document.signedOne party signed, others outstanding. Carries data.party - who just signed.
document.declinedSomeone declined, with their reason.
document.viewedA party opened their link for the first time. Carries data.party - who opened it. Fires once per party, not on every repeat visit.
document.expiredThe document passed its expiry date unsigned.

Payload

{
  "id": "9f1c8e7a-4d2b-4a1e-b3f5-0c8d9e2a1b4c",
  "type": "document.completed",
  "created_at": "2026-08-07T14:09:12+00:00",
  "data": {
    "document": {
      "id": 42,
      "uuid": "b3dab98b-6787-4570-b976-aa5f8fe6cc7c",
      "title": "Roofing works - 14 Mill Lane",
      "external_ref": "JOB-1042",
      "status": "completed",
      "completed_at": "2026-08-07T14:09:12+00:00",
      "sha256": "e5594c1a641816905f9168c8b0f06bf8...",
      "download_url": "https://app.getsigning.co.uk/api/v1/documents/42/download"
    },
    "parties": [
      {
        "first_name": "Sarah",
        "last_name": "Whitfield",
        "email": "sarah@acme.co.uk",
        "signed_at": "2026-08-07T14:02:41+00:00"
      }
    ]
  }
}

id is unique to each event. A retry sends the same id, so record the ones you have processed and ignore duplicates.

document.signed and document.viewed both carry a data.party object instead of a parties array, naming whichever one triggered the event:

{
  "id": "2a7f9c1e-8b3d-4f6a-9c2e-1d8b4a7f9c1e",
  "type": "document.signed",
  "created_at": "2026-08-07T13:52:03+00:00",
  "data": {
    "document": {
      "id": 42,
      "uuid": "b3dab98b-6787-4570-b976-aa5f8fe6cc7c",
      "title": "Roofing works - 14 Mill Lane",
      "status": "partially_signed",
      "external_ref": "JOB-1042"
    },
    "party": {
      "id": 88,
      "first_name": "Sarah",
      "last_name": "Whitfield",
      "email": "sarah@acme.co.uk"
    },
    "remaining": 1
  }
}

Verifying a payload

Every request carries a signature header. Check it before acting on anything: without this, anyone who learns your endpoint URL could post fake completions to it.

X-GetSigning-Signature: t=1754575752,v1=5f8a3c9e2b1d4a7f...
X-GetSigning-Timestamp: 1754575752

v1 is HMAC-SHA256 over the string <timestamp>.<raw request body>, keyed with your endpoint's signing secret.

$secret = 'whsec_your_secret_here';
$body   = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_GETSIGNING_SIGNATURE'] ?? '';

parse_str(strtr($header, ',', '&'), $parts);

$expected = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret);

// hash_equals, not ==, so the comparison cannot be timed.
if (!hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);
    exit;
}

// Reject anything old, or a captured request could be replayed later.
if (abs(time() - (int)$parts['t']) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);

if ($event['type'] === 'document.completed') {
    $jobRef = $event['data']['document']['external_ref'];
    // ... mark the job as signed, fetch the PDF, whatever you need
}

http_response_code(200);
Hash the raw body

Parse the JSON only after checking the signature. Re-encoding a parsed object changes the bytes, and the hash will never match. In Express this means express.raw(), not express.json().

Retries and failures

Reply with any 2xx. Anything else, or a timeout after 15 seconds, is treated as a failure and retried up to six times with growing gaps: 1, 5, 25, 125 and 625 minutes, so roughly thirteen hours in total. After that it is given up on.

Every attempt, with its response code and any error, is listed under Settings, API. Redirects are never followed, so a signed payload cannot be walked somewhere you did not authorise.

Document statuses

StatusMeaning
draftCreated but not sent. Only possible with "send": false.
sentOut with the parties, nobody has signed yet.
partially_signedAt least one has signed, at least one has not.
completedEveryone signed. The signed PDF is available.
declinedSomeone declined. The document is stopped.
voidedYou withdrew it.
expiredIt passed its expiry date unsigned.

Errors

Errors are always JSON, never HTML, whatever went wrong.

{
  "error": {
    "type": "no_credits",
    "message": "You have no document credits left."
  }
}
CodeTypeMeaning
400invalid_requestThe body was not valid JSON.
401unauthorisedMissing, unknown or revoked key.
402no_creditsNot enough credits to send.
403forbiddenThe key lacks the scope for this call.
404not_foundNo such document on this account.
409not_readyThe signed PDF is not available yet.
409cannot_voidCompleted documents cannot be withdrawn.
409not_pendingOnly documents awaiting signature can be reminded.
409cannot_resendSee sending a draft later - either nobody is currently due to sign, or the current party is one we email ourselves.
413file_too_largeOver the 26 MB limit.
422missing_titleNo title.
422missing_fileNo file_base64.
422bad_filefile_base64 is not valid base64.
422bad_documentThe file could not be prepared. Encrypted PDFs are the usual cause.
422missing_partiesNo parties supplied.
422too_many_partiesMore than 25.
422bad_partyA party is missing a name or has an invalid email.
422missing_fieldsNo fields supplied.
422bad_fieldA field refers to a party that was not supplied.
422bad_fieldsA field is off the page, or of an unknown type.
422cannot_sendSending failed, usually because a signer has no fields.
429Rate limited. Wait and retry.
A failed create leaves nothing behind

If the parties are accepted but a field is wrong, the whole document is removed before the error is returned. You will never find a half-made document in your account after a rejected request, and retrying is always safe.

Security notes

A few things worth knowing about how this is built.

Keys are stored hashed

We keep a SHA-256 hash of your key, never the key itself. A stolen database backup contains nothing that can call the API. It also means we genuinely cannot tell you what your key is if you lose it.

Accounts are isolated at the query level

Every lookup is scoped to the account the key belongs to. A document belonging to another account returns 404, not 403, so the API never confirms that an id exists on someone else's account.

We will not fetch a URL for you

Documents must be sent as base64. We deliberately do not accept a URL to download from, because that would let anyone use our servers to reach addresses they cannot reach themselves. The same reasoning is why webhook endpoints must be public https addresses.

Everything is audited

Documents created through the API are marked as such, and every send, open, reminder and signature is recorded in the same hash-chained audit trail as documents created in the web app. The API is not a side door around the evidence.

Something missing?

If there is an endpoint you need that is not here, raise a support ticket in the app: sign in and click Support. That is the only way to reach us, so that your question arrives with your account, your keys and your previous tickets already attached. A real person reads it, usually the same working day.