Home · API
One key, one read and one notification. Nothing else, on purpose.
SektorSign's public API lets another system —a CRM, an ERP, an internal dashboard— know where a contract stands and start a new one without anyone copying data by hand. It is small because what matters is that it does not change.
Base URL and version
Everything hangs off one address with the version inside it. It sits apart from the application API on purpose: what the application uses internally can change whenever the product needs it; what lives here is a commitment to third-party systems and is not broken without notice.
https://sektorsign.com/api/public/v1When there is a v2, v1 will keep answering. A new field may appear at any time —your integration must ignore fields it does not know— but no existing field is removed or changes meaning within the same version.
Authentication
Every request carries a workspace key in the header:
Authorization: Bearer ss_live_…How to get a key
- Sign in and open Settings → Integrations.
- Create a key and give it a name that says what it is for ("CRM", "billing"): that way you know which one to revoke the day you need to.
- Copy it there and then. It is shown once: the database keeps only its SHA-256, so not even support can recover it. If it is lost, revoke it and create another.
What a key can and cannot do
A key grants access to a whole workspace, with no notion of a person. It is not a session: no cookies, no CSRF check and no expiry of its own. And it does not slip past the control plane: if the workspace is suspended or frozen, the key stops working immediately.
Treat it like a password: in a server environment variable, never in browser code and never in a repository. The prefix exists so you recognise it at a glance in a log, and so a secret scanner finds it before someone else does.
Check the key is alive
GET/ping
The first thing worth trying. Returns the workspace it grants access to.
curl https://sektorsign.com/api/public/v1/ping \
-H "Authorization: Bearer $SEKTORSIGN_API_KEY"
{ "ok": true, "workspaceId": "7c1f…" }List contracts
GET/contracts
The contracts in the workspace, most recently moved first.
Parameters
| limit | How many to return. Between 1 and 100; 25 by default. |
|---|---|
| status | Filter by status. One of the ones below. |
curl "https://sektorsign.com/api/public/v1/contracts?status=sent&limit=50" \
-H "Authorization: Bearer $SEKTORSIGN_API_KEY"There is no cursor pagination yet: a capped page and nothing more. When someone has enough contracts for it to matter, it will be added without breaking this.
Read one contract
GET/contracts/:id
curl https://sektorsign.com/api/public/v1/contracts/7c1f… \
-H "Authorization: Bearer $SEKTORSIGN_API_KEY"What a contract returns
It is deliberately poorer than what the application sees: no storage keys, no token hashes, no IP addresses and no full history. What an outside system needs in order to react, and nothing more. Every field added here would be a commitment that could no longer be taken back.
{
"contract": {
"id": "7c1f…",
"titulo": "Propuesta de servicios",
"estado": "sent",
"creadoEl": "2026-08-19T09:12:44.106Z",
"enviadoEl": "2026-08-19T09:20:01.550Z",
"firmadoEl": null,
"venceEl": "2026-09-19",
"firmantes": [
{
"nombre": "Marta Ruiz",
"email": "marta@ejemplo.com",
"papel": "signer",
"turno": 1,
"haFirmado": false
}
]
}
}The statuses
| draft | It exists, with its PDF, but it is not ready to send. |
|---|---|
| prepared | It has signers and placed fields; it still needs sending. |
| sent | Invitations have gone out. Nobody has opened it yet. |
| viewed | Someone has opened its link. |
| signed | No signature is missing. |
| completed | The signed PDF and the evidence report also exist. |
| expired | Its expiry date passed without completion. |
List templates
GET/templates
So you know which one to create from. Returns its title pattern and version.
{
"templates": [
{
"id": "31ab…",
"name": "Propuesta estándar",
"titlePattern": "Propuesta · {{cliente}}",
"version": 3
}
]
}Create a draft from a template
POST/templates/:id/contracts
The only thing the API writes. Variables you do not send keep their braces —`{{client}}`— so what is missing is visible instead of leaving a silent gap. If you would rather write the whole title instead of using the template pattern, send `titulo`.
curl -X POST https://sektorsign.com/api/public/v1/templates/31ab…/contracts \
-H "Authorization: Bearer $SEKTORSIGN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "variables": { "cliente": "Marta Ruiz" } }'
201 Created
{ "contractId": "9d0e…" }It creates a draft, and stops there
A person still has to sign in, review it, add the signers and send it. A program can prepare the work; sending someone a contract is still a decision with a name behind it.
The contract is attributed to whoever created the template. A key is not a person, and authorship has to land on someone real: whoever built the template is the honest choice, because it is their work being reused.
What the API does not do, and why
- It does not sign. Signing requires a person to see the document, accept a consent statement and leave their IP address and the server time in the evidence. An API key is not a person, and a contract signed by a program would be exactly what this product holds cannot happen.
- It deletes nothing. Everything irreversible stays in the application, behind a session and a confirmation.
- It does not download documents. The signed PDF and the evidence report are downloaded from the application, where it is recorded who downloaded them.
Errors
Always the same body, with the matching HTTP status. The `message` text is written for a person and may change; what does not change within a version is `code`.
{ "error": { "code": "unauthorized", "message": "Esa llave de API no vale." } }| Status | When |
|---|---|
| 400 validation_error | The body does not validate. `fields` says which field and why. |
| 401 unauthorized | The key is missing, invalid, revoked, or the workspace is not active. |
| 404 not_found | That contract or template does not exist in this workspace. |
| 500 internal_error | Our fault. Retry with increasing backoff. |
Webhooks
Asking every minute whether a contract changed is expensive and late. A webhook turns it around: SektorSign calls your address when something happens. They are set up in Settings → Integrations, and over https only —a notification over HTTP travels in the clear across the internet with a contract title and a signer name inside it.
Events
| contract.sent | Invitations have gone out. |
|---|---|
| contract.viewed | Someone opened the signing link for the first time. |
| contract.signed | No signature is missing any more. |
| contract.completed | The signed PDF and the evidence report are ready. |
| contract.expired | It expired without completing. |
It is a small, stable subset of the contract's history: the moments when an outside system has something to do. The full history is not exposed because every new product event would become a public commitment that could no longer be changed.
The notification is queued inside the same transaction that causes the fact. If the contract never gets sent, no notification saying it was sent goes out either.
The delivery body
{
"evento": "contract.signed",
"datos": { "contractId": "7c1f…", "firmantes": 2 }
}The headers
| x-sektorsign-signature | The signature, as `sha256=<hex>`. |
|---|---|
| x-sektorsign-timestamp | Seconds since the epoch, at the moment of sending. |
| x-sektorsign-event | The event name, so you can route without opening the body. |
| x-sektorsign-delivery | Unique id for this delivery. Use it so you do not process twice. |
Verifying the signature
What is signed is `<timestamp>.<body>`, with HMAC-SHA256 and the webhook secret. The timestamp goes inside the signature so an intercepted delivery cannot be replayed tomorrow: compare it against your clock and discard anything old.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCIA = 300 // segundos
export function valida(cuerpoCrudo, cabeceras, secreto) {
const momento = cabeceras['x-sektorsign-timestamp']
const recibida = cabeceras['x-sektorsign-signature']
if (!momento || !recibida) return false
// Fuera lo viejo: una entrega interceptada no se reenvía mañana.
const edad = Math.abs(Math.floor(Date.now() / 1000) - Number(momento))
if (!Number.isFinite(edad) || edad > TOLERANCIA) return false
const esperada =
'sha256=' +
createHmac('sha256', secreto)
.update(`${momento}.${cuerpoCrudo}`)
.digest('hex')
const a = Buffer.from(esperada)
const b = Buffer.from(recibida)
return a.length === b.length && timingSafeEqual(a, b)
}Compare against the **raw** body, exactly as it arrived. If you run it through `JSON.parse` and serialise it again, one space of difference brings the signature down.
And compare in constant time. A `===` over a signature leaks, byte by byte, how much you got right.
Retries and automatic shut-off
Your endpoint has ten seconds to answer 2xx. If it does not answer, or answers something else, it is retried with increasing backoff: one minute, five, twenty-five. (4)
A webhook that piles up consecutive failures turns itself off, and the application shows it off with its last response code. Turning it back on by hand forgives the accumulated failures, so it does not shut off again at the first stumble because of the previous round. (10)
Design your endpoint to receive the same event twice. A retry after your server processed it but never got to answer is exactly that.
Something missing?
This API is small by decision, not by neglect: it grows when someone actually needs it, not to fill a catalogue. If you are missing an endpoint to integrate SektorSign with your system, write to us and we will talk it through. info@sektorsign.com