API documentation
Send contracts for signature from your own software. Create a document, place the fields, and get told when everyone has signed.
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.
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:
$key = 'gsk_your_key_here';
$ch = curl_init('https://app.getsigning.co.uk/api/v1');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => $key . ':', // password is ignored
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($res['account']);
const key = 'gsk_your_key_here';
const auth = 'Basic ' + Buffer.from(key + ':').toString('base64');
const res = await fetch('https://app.getsigning.co.uk/api/v1', {
headers: { Authorization: auth }
});
const data = await res.json();
console.log(data.account);
import requests
key = 'gsk_your_key_here'
r = requests.get('https://app.getsigning.co.uk/api/v1',
auth=(key, '')) # password ignored
r.raise_for_status()
print(r.json()['account'])
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"
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" }
}
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 }
]
}'
$key = 'gsk_your_key_here';
$payload = [
'title' => 'Roofing works - 14 Mill Lane',
'external_ref' => 'JOB-1042', // your own job number
'filename' => 'contract.pdf',
'file_base64' => base64_encode(file_get_contents('contract.pdf')),
'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],
],
];
$ch = curl_init('https://app.getsigning.co.uk/api/v1/documents');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => $key . ':',
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$res = json_decode($body, true);
if ($code !== 201) {
throw new RuntimeException($res['error']['message'] ?? 'Request failed');
}
// Store this against your job, then wait for the webhook.
$documentId = $res['document']['id'];
import fs from 'node:fs';
const key = 'gsk_your_key_here';
const auth = 'Basic ' + Buffer.from(key + ':').toString('base64');
const res = await fetch('https://app.getsigning.co.uk/api/v1/documents', {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Roofing works - 14 Mill Lane',
external_ref: 'JOB-1042',
filename: 'contract.pdf',
file_base64: fs.readFileSync('contract.pdf').toString('base64'),
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 }
]
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error.message);
console.log('Sent, id', data.document.id);
import base64, requests
key = 'gsk_your_key_here'
with open('contract.pdf', 'rb') as f:
encoded = base64.b64encode(f.read()).decode()
r = requests.post(
'https://app.getsigning.co.uk/api/v1/documents',
auth=(key, ''),
json={
'title': 'Roofing works - 14 Mill Lane',
'external_ref': 'JOB-1042',
'filename': 'contract.pdf',
'file_base64': encoded,
'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}
]
})
r.raise_for_status()
print('Sent, id', r.json()['document']['id'])
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
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
| Field | Description | |
|---|---|---|
title | REQUIRED | Shown in the email subject and in your dashboard. |
file_base64 | REQUIRED | The document, base64 encoded. PDF, Word, ODT, spreadsheets and images are accepted; anything that is not already a PDF is converted. Maximum 26 MB. |
parties | REQUIRED | Array, 1 to 25. See below. |
fields | REQUIRED | Array. Every signer needs at least one, or they receive a document they cannot action. |
filename | OPTIONAL | Used to work out the format. Defaults to document.pdf. |
external_ref | OPTIONAL | Your own identifier. Returned on every response and every webhook, so you never need to store a mapping. |
reference | OPTIONAL | A reference shown to you in the dashboard. |
message | OPTIONAL | A short note included in the invitation email. |
signing_order | OPTIONAL | parallel (default) sends to everyone at once. sequential invites each party only once the previous one has signed. |
expires_in_days | OPTIONAL | 1 to 365. Links stop working afterwards. |
send | OPTIONAL | false creates it as a draft without sending or consuming a credit. |
Parties
| Field | Description | |
|---|---|---|
first_name | REQUIRED | |
last_name | REQUIRED | |
email | REQUIRED | Where their private signing link goes. |
key | OPTIONAL | A name you choose, used by fields.party to say who fills what. Without one, the array index is used. |
company | OPTIONAL | Appears on the signature certificate. |
role | OPTIONAL | Free text, for example Client or Witness. |
type | OPTIONAL | signer (default), approver, or viewer for someone who only needs to see it. |
receives_copy | OPTIONAL | 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_email | OPTIONAL | 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
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.
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.
| Field | Description | |
|---|---|---|
party | REQUIRED | The key of the party who fills this in. |
type | REQUIRED | See the table below. |
page | REQUIRED | 1 based. |
x, y | REQUIRED | 0 to 1, from the top left corner. |
w, h | REQUIRED | 0 to 1. A signature is usually around 0.25 by 0.05. |
required | OPTIONAL | Defaults to true. A required field must be completed before the signer can finish. |
label | OPTIONAL | Shown to the signer as a prompt. |
group | OPTIONAL | For radio only. See choice groups. |
font_size | OPTIONAL | In points. Defaults to 10. |
align | OPTIONAL | left, center or right. |
date_format | OPTIONAL | For date. One of d/m/Y, j F Y, d M Y, Y-m-d. |
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
| Type | What the signer does |
|---|---|
signature | Draws with a finger or mouse, or types their name. Drawn signatures are captured as strokes and redrawn as vectors, so they stay sharp. |
initials | As above, smaller. Useful for initialling each page. |
date | Picks a date. Defaults to the day they sign. |
text | Types free text. |
checkbox | Ticks a box. Independent of any other box. |
radio | Chooses one option from a group. See below. |
fullname | Filled automatically from the party record. The signer cannot change it. |
email | Filled automatically. Not editable. |
company | Filled automatically. Not editable. |
title | The 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
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
| Query | Description |
|---|---|
status | Filter by status. |
limit | 1 to 100. Defaults to 25. |
offset | For 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
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
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
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
| Event | Fires when |
|---|---|
document.completed | Every party has signed. Carries the download URL and the signed file's hash. |
document.signed | One party signed, others outstanding. Carries data.party - who just signed. |
document.declined | Someone declined, with their reason. |
document.viewed | A party opened their link for the first time. Carries data.party - who opened it. Fires once per party, not on every repeat visit. |
document.expired | The 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);
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = 'whsec_your_secret_here';
// The RAW body is required. Parsed JSON will not hash to the same value.
app.post('/getsigning-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.headers['x-getsigning-signature'] || '';
const parts = Object.fromEntries(
header.split(',').map(p => p.split('='))
);
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.' + req.body)
.digest('hex');
const ok = parts.v1 && crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(parts.v1)
);
if (!ok) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - parts.t) > 300) {
return res.sendStatus(400); // replay
}
const event = JSON.parse(req.body);
if (event.type === 'document.completed') {
console.log('Signed:', event.data.document.external_ref);
}
res.sendStatus(200);
});
import hmac, hashlib, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = 'whsec_your_secret_here'
@app.route('/getsigning-webhook', methods=['POST'])
def webhook():
body = request.get_data() # raw bytes, not parsed JSON
header = request.headers.get('X-GetSigning-Signature', '')
parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
expected = hmac.new(
SECRET.encode(),
(parts.get('t', '') + '.').encode() + body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, parts.get('v1', '')):
abort(400)
if abs(time.time() - int(parts['t'])) > 300:
abort(400) # replay
event = request.get_json()
if event['type'] == 'document.completed':
print('Signed:', event['data']['document']['external_ref'])
return '', 200
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
| Status | Meaning |
|---|---|
draft | Created but not sent. Only possible with "send": false. |
sent | Out with the parties, nobody has signed yet. |
partially_signed | At least one has signed, at least one has not. |
completed | Everyone signed. The signed PDF is available. |
declined | Someone declined. The document is stopped. |
voided | You withdrew it. |
expired | It 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."
}
}
| Code | Type | Meaning |
|---|---|---|
400 | invalid_request | The body was not valid JSON. |
401 | unauthorised | Missing, unknown or revoked key. |
402 | no_credits | Not enough credits to send. |
403 | forbidden | The key lacks the scope for this call. |
404 | not_found | No such document on this account. |
409 | not_ready | The signed PDF is not available yet. |
409 | cannot_void | Completed documents cannot be withdrawn. |
409 | not_pending | Only documents awaiting signature can be reminded. |
409 | cannot_resend | See sending a draft later - either nobody is currently due to sign, or the current party is one we email ourselves. |
413 | file_too_large | Over the 26 MB limit. |
422 | missing_title | No title. |
422 | missing_file | No file_base64. |
422 | bad_file | file_base64 is not valid base64. |
422 | bad_document | The file could not be prepared. Encrypted PDFs are the usual cause. |
422 | missing_parties | No parties supplied. |
422 | too_many_parties | More than 25. |
422 | bad_party | A party is missing a name or has an invalid email. |
422 | missing_fields | No fields supplied. |
422 | bad_field | A field refers to a party that was not supplied. |
422 | bad_fields | A field is off the page, or of an unknown type. |
422 | cannot_send | Sending failed, usually because a signer has no fields. |
429 | Rate limited. Wait and retry. |
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.
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.