API REST per creare richieste di firma elettronica semplice con OTP e seguirne l'esito.
L'indirizzo base è https://app.nuvasign.it/v1. Le chiavi API si creano dalla console, sezione
Sviluppatori.
Autenticazione
Ogni chiamata porta la chiave API nell'header:
Authorization: Bearer nsk_live_xxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyLa chiave viene mostrata una sola volta alla creazione. NuvaSign ne conserva solo l'hash: se la perdi, revocala e creane un'altra.
Ogni chiave ha degli scope:
| Scope | Permette |
|---|---|
signature_requests:write |
creare e annullare richieste |
signature_requests:read |
leggere ed elencare richieste |
credits:read |
leggere firme e movimenti |
Chiave di prova (sandbox)
Per integrare senza conseguenze crea una chiave di prova dalla console (Sviluppatori → Nuova chiave →
Chiave di prova). Ha prefisso nsk_test_ invece di nsk_live_, così la riconosci a colpo d'occhio in un
file di configurazione:
Authorization: Bearer nsk_test_xxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyNon è un ambiente separato e non è un URL diverso: stesse rotte, stesso corpo, stesso tuo codice. Passi in produzione cambiando la chiave, e nient'altro. È voluto: un ambiente a parte ti farebbe provare un percorso diverso da quello vero, e scopriresti le differenze in produzione.
Resta identico tutto ciò che scrivi tu: creazione, validazione, campi per firmatario, short-link, cerimonia,
firma, tentativi del codice, stati, eventi, webhook — compreso signature_request.error, perché anche il ramo
d'errore va potuto provare.
Cambia solo ciò che avrebbe conseguenze fuori dalla piattaforma:
| Chiave reale | Chiave di prova | |
|---|---|---|
| Addebito | sì | nessuno |
| Sigillo e marca temporale | sì | nessuno: il PDF esce con la filigrana «TEST — senza valore legale» |
| Codice OTP | casuale, inviato via SMS o email | sempre 000000, non inviato |
| Limite per chiave | 120 al minuto | 60 al minuto |
Le due chiavi non si vedono a vicenda: con una chiave reale una richiesta di prova risponde 404 in lettura,
elenco, annullo e download — e viceversa. Se ricevi 404 su una richiesta che hai appena creato, controlla di
non aver mescolato le chiavi.
I documenti di prova non hanno alcun valore legale. La filigrana è su ogni pagina proprio perché il file, una volta uscito da qui, non venga scambiato per un documento firmato.
Limiti
- 120 richieste al minuto per chiave, 60 con una chiave di prova. Ogni risposta riporta
RateLimit-Limit,RateLimit-RemainingeRateLimit-Reset; oltre il limite si riceve429conRetry-Afterin secondi.RateLimit-Limitriporta sempre il tetto davvero applicato a quella chiave. - PDF fino a 10 MB, non protetto da password.
- Metadati fino a 4 KB.
Errori
Tutti gli errori usano application/problem+json (RFC 9457):
{
"type": "urn:nuvasign:error:insufficient_credits",
"title": "Crediti insufficienti",
"status": 402,
"code": "insufficient_credits",
"detail": "Firme insufficienti per creare la richiesta",
"request_id": "01M2JA0MSZT7ENXVE87BF57XMX"
}Indica sempre request_id quando contatti l'assistenza. Il campo code è stabile e va usato per
decidere cosa fare; title e detail sono testi per le persone e possono cambiare. type è un
identificativo (urn:nuvasign:error:<code>), non un indirizzo da aprire.
| Status | code |
Quando |
|---|---|---|
| 400 | validation_error |
corpo o parametri non validi; il dettaglio campo per campo è in errors |
| 401 | unauthorized |
chiave assente, errata o revocata |
| 402 | insufficient_credits |
firme insufficienti: comprane altre e ritenta |
| 402 | subscription_required |
account in abbonamento: prova finita, firme del periodo terminate, pagamento non riuscito oltre la tolleranza o abbonamento non attivo |
| 403 | forbidden |
scope mancante o account sospeso |
| 404 | not_found |
risorsa inesistente o di un altro account |
| 409 | conflict |
external_id già usato, oppure transizione di stato non ammessa |
| 409 | idempotency_in_progress |
una richiesta identica è ancora in elaborazione: riprova dopo Retry-After |
| 422 | idempotency_key_reuse |
la stessa Idempotency-Key è stata usata con un corpo diverso |
| 422 | unprocessable_document |
PDF non valido, cifrato, troppo grande, URL non raggiungibile o segnaposto mancante |
| 429 | rate_limited |
limite di richieste superato |
Idempotenza
POST /v1/signature-requests richiede l'header Idempotency-Key (fino a 255 caratteri, per esempio un
UUID generato da te per ogni richiesta logica).
- Stessa chiave e stesso corpo: ricevi la risposta originale, con l'header
Idempotent-Replay: true. Nessuna firma consumata una seconda volta. - Stessa chiave e corpo diverso:
422 idempotency_key_reuse. - Stessa chiave mentre la prima chiamata è ancora in corso:
409 idempotency_in_progressconRetry-After. Succede se vai in timeout e ritenti subito. - Se la richiesta fallisce (qualunque risposta ≥ 400) la chiave si libera: puoi ritentare con la stessa chiave, per esempio dopo aver comprato altre firme.
Le chiavi scadono dopo 24 ore.
Crea una richiesta di firma
POST /v1/signature-requestsScope signature_requests:write. Header Idempotency-Key obbligatorio.
Il documento
Indica esattamente una fonte:
| Campo | Descrizione |
|---|---|
document.base64 |
il PDF codificato in base64 |
document.url |
un URL https da cui scaricare il PDF. Niente redirect, niente indirizzi di rete interna. |
document.template_id |
un template creato dalla console (disponibile con l'editor dei template) |
Senza template, il PDF deve contenere un segnaposto per ogni firmatario, scritto come testo nel documento:
{{firma:1}} riquadro di firma 180 x 40 punti
{{firma:2|200x50}} riquadro di 200 x 50 puntiIl numero indica il firmatario nell'ordine dell'array signers, partendo da 1. Il segnaposto può stare sulla stessa riga di altro testo (Firma del cliente: {{firma:1}}): il riquadro parte dal segnaposto. Il riquadro poggia sulla
linea di base del segnaposto e cresce verso l'alto. NuvaSign copre i segnaposto di bianco: il firmatario
non li vede. Se manca il segnaposto di un firmatario la richiesta viene rifiutata con 422.
Corpo
| Campo | Tipo | Obbligatorio | Descrizione |
|---|---|---|---|
document |
oggetto | si | vedi sopra |
signers |
array, 1–20 | si | i firmatari |
signers[].first_name |
stringa | si | |
signers[].last_name |
stringa | si | |
signers[].email |
stringa | se otp o delivery sono email |
|
signers[].phone |
stringa E.164 | se otp o delivery sono sms |
es. +393331234567 |
signers[].otp |
sms | email |
si | canale su cui il firmatario riceve il codice |
delivery |
none | sms | email |
no, default none |
con none il link lo invii tu |
with_timestamp |
booleano | no, ignorato | la marca temporale si applica sempre: il campo resta accettato per compatibilità |
expires_in_hours |
intero 1–168 | no, default 72 |
|
title |
stringa fino a 200 | no | titolo mostrato al firmatario; senza, "Documento da firmare" |
external_id |
stringa fino a 128 | no | tuo identificativo, unico nel tuo account |
metadata |
oggetto fino a 4 KB | no | restituito invariato |
Sulla marca temporale. Si applica sempre, su ogni documento: senza, il documento firmato non conterrebbe una prova opponibile di quando è stato firmato. Il campo
with_timestampresta accettato per non rompere le integrazioni esistenti, ma non ha più effetto.
Esempio
curl -X POST https://app.nuvasign.it/v1/signature-requests \
-H "Authorization: Bearer $NUVASIGN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f1c6e0e-7b8a-4d1e-9c4a-0f2b7a1d9e55" \
-d '{
"document": { "base64": "JVBERi0xLjcK..." },
"signers": [
{ "first_name": "Mario", "last_name": "Rossi", "phone": "+393331234567", "otp": "sms" },
{ "first_name": "Luisa", "last_name": "Bianchi", "email": "[email protected]", "otp": "email" }
],
"delivery": "none",
"title": "Contratto di fornitura",
"external_id": "ordine-001"
}'Risposta 201
{
"id": "01M2JA0MSZT7ENXVE87BF57XMX",
"external_id": "ordine-001",
"title": "Contratto di fornitura",
"status": "pending",
"delivery": "none",
"with_timestamp": true,
"expires_at": "2026-09-18T10:32:46.355Z",
"created_at": "2026-09-15T10:32:46.358Z",
"completed_at": null,
"signature_breakdown": {
"billing": "signatures",
"signers": 2,
"documents": 1,
"signatures": 2
},
"metadata": null,
"signers": [
{
"id": "01M2JA0NGZC4BDKJG0Y9RTEEBF",
"idx": 0,
"first_name": "Mario",
"last_name": "Rossi",
"status": "created",
"sign_url": "https://app.nuvasign.it/f/ARF99V1ATVDW",
"signed_at": null
}
],
"files": { "sealed": null, "audit": null }
}sign_url è il link personale del firmatario: con delivery: none sei tu a consegnarlo; con sms o email lo consegna NuvaSign subito dopo la creazione, e il firmatario passa a notified. Chi possiede
il link può accedere alla firma fino alla verifica OTP: trattalo come un dato riservato.
Firme consumate
Alla creazione si scalano firme, non denaro: il conteggio resta congelato in signature_breakdown.
firme = firmatari x documentiUna firma è un firmatario su un documento. Sigillo, marca temporale qualificata e codice OTP sono sempre compresi; gli SMS in più (altri firmatari, reinvii del codice) sono a nostro carico. Scadenza, rifiuto e annullo non restituiscono firme: il documento è stato inviato.
Le firme si comprano in anticipo, a pacchetti o a quantità libera. Quando finiscono, la creazione risponde
402 insufficient_credits; per un account in abbonamento con il periodo esaurito, 402 subscription_required.
In entrambi i casi il motivo è in detail e nessuna richiesta viene creata.
Account in abbonamento. Se il tuo account è nato dalla registrazione sul sito, le richieste scalano le
firme del periodo (prova gratuita o piano) e signature_breakdown.billing è "subscription".
Leggi una richiesta
GET /v1/signature-requests/{id}Scope signature_requests:read. Stessa forma della risposta di creazione.
Elenca le richieste
GET /v1/signature-requests?status=pending&external_id=ordine-001&limit=25&cursor=...Scope signature_requests:read. Dalla più recente.
| Parametro | Descrizione |
|---|---|
status |
pending, processing, completed, expired, declined, error, canceled |
external_id |
filtra per il tuo identificativo |
limit |
1–100, default 25 |
cursor |
il next_cursor della pagina precedente |
{ "data": [{ "id": "..." }], "next_cursor": "01M2J9ZV48G4VCCHHN37Q74663" }next_cursor è null sull'ultima pagina.
Annulla una richiesta
POST /v1/signature-requests/{id}/cancelScope signature_requests:write. Solo per richieste pending: altrimenti 409. I link di firma smettono
subito di funzionare. Nessun rimborso.
Crediti
GET /v1/creditsScope credits:read. Firme disponibili e ultimi 50 movimenti, dal più recente.
{
"billing_mode": "prepaid",
"signatures_available": 223,
"subscription": null,
"movements": [
{
"id": "01M2JA0P3XQ8Y0Z5B7K2D9N4FT",
"delta_signatures": -2,
"reason": "consume",
"request_id": "01M2JA0MSZT7ENXVE87BF57XMX",
"note": null,
"balance_after_signatures": 223,
"created_at": "2026-09-15T10:32:46.402Z"
}
]
}reason: purchase (firme comprate), consume (firme usate da una richiesta), restore (firme
restituite da una richiesta chiusa in errore), adjust (rettifica dall'assistenza).
Tutto è contato in firme. Non esiste un saldo in denaro e non si ricarica alcun credito: si comprano
firme e si consumano firme. signatures_available è un numero esatto, non una stima ricavata da un saldo.
La risposta indica anche il modo di fatturazione. Per un account a consumo billing_mode è "prepaid" e
subscription è null. Per un account in abbonamento billing_mode è "subscription" e conta
l'utilizzo del periodo. Le firme si usano in quest'ordine: prima le incluse nel periodo, poi quelle
comprate in anticipo. Non esistono eccedenze: quando entrambe sono finite la creazione risponde 402.
used_signatures comprende anche le firme prese da quelle comprate, che sono indicate a parte in
credit_signatures:
{
"billing_mode": "subscription",
"subscription": {
"state": "active",
"period": {
"kind": "trial",
"starts_at": "2026-09-15T10:00:00.000Z",
"ends_at": "2026-10-15T10:00:00.000Z",
"included_signatures": 10,
"used_signatures": 3,
"credit_signatures": 0,
"sms_limit": 20,
"used_sms": 2
}
},
"movements": []
}state: active, trial_exhausted (firme della prova finite), trial_expired (prova scaduta),
no_period (nessun periodo attivo).
Stati
| Richiesta | Significato |
|---|---|
pending |
in attesa delle firme |
processing |
tutti hanno firmato, documento in chiusura |
completed |
documento firmato e sigillato, scaricabile |
expired |
scaduta prima che tutti firmassero |
declined |
un firmatario ha rifiutato |
canceled |
annullata da te |
error |
la chiusura del documento non è riuscita; le firme raccolte non vanno perse e il supporto interviene |
| Firmatario | Significato |
|---|---|
created |
creato, link non ancora consegnato |
notified |
link consegnato da NuvaSign |
opened |
ha aperto il link |
consented |
ha accettato di firmare |
signed |
ha firmato e verificato l'OTP |
declined |
ha rifiutato |
expired, canceled |
la richiesta si è chiusa prima della sua firma |
Documento sigillato e pacchetto di prova
GET /v1/signature-requests/{id}/files/sealed
GET /v1/signature-requests/{id}/files/auditScope signature_requests:read. Rispondono 302 verso un indirizzo temporaneo valido 15 minuti:
segui il redirect (con curl, -L). 404 finché la richiesta non è completed.
sealed: il PDF firmato, con le firme applicate, una pagina di attestazione in coda e un sigillo elettronico PAdES che rende rilevabile qualunque modifica, con una marca temporale qualificata (livelloPAdES-BASELINE-LT): si applica sempre, qualunque cosa dicawith_timestamp.audit: il pacchetto di prova in JSON canonico (RFC 8785): eventi della richiesta con catena di hash, dati dei firmatari, esiti dei codici OTP (mai i codici). Il suo SHA-256 è stampato nella pagina di attestazione del PDF sigillato, che quindi lo copre.
Nel campo files delle risposte trovi gli stessi indirizzi quando i file sono pronti.
Webhook
NuvaSign ti avvisa con una richiesta POST al tuo endpoint quando una richiesta cambia stato. Gli
endpoint si configurano dalla console, sezione Sviluppatori, dove trovi anche il secret e lo storico delle
consegne.
Eventi
| Evento | Quando |
|---|---|
signature_request.completed |
documento firmato da tutti e sigillato: i file sono scaricabili |
signature_request.declined |
un firmatario ha rifiutato |
signature_request.expired |
scaduta prima che tutti firmassero |
signature_request.canceled |
annullata |
signature_request.error |
la chiusura del documento non è riuscita; le firme raccolte non vanno perse |
signer.signed |
un firmatario ha firmato. Solo se lo sottoscrivi esplicitamente: con * non arriva |
Richiesta
POST /il-tuo-endpoint
Content-Type: application/json
User-Agent: NuvaSign-Webhooks/1.0
X-NuvaSign-Event: signature_request.completed
X-NuvaSign-Delivery: 01M2JH3Q7T0X9B6N2R5W8K4ZCD
X-NuvaSign-Signature: t=1789470000,v1=5f0c...e21a{
"event": "signature_request.completed",
"occurred_at": "2026-09-15T12:00:00.000Z",
"data": {
"id": "01M2JA0MSZT7ENXVE87BF57XMX",
"external_id": "ordine-001",
"title": "Contratto di fornitura",
"status": "completed",
"metadata": { "pratica": "A-42" },
"signers": [
{
"id": "01M2JA0NGZC4BDKJG0Y9RTEEBF",
"idx": 0,
"first_name": "Mario",
"last_name": "Rossi",
"status": "signed",
"signed_at": "2026-09-15T11:59:02.000Z"
}
],
"files": {
"sealed": "https://app.nuvasign.it/v1/signature-requests/01M2JA0MSZT7ENXVE87BF57XMX/files/sealed",
"audit": "https://app.nuvasign.it/v1/signature-requests/01M2JA0MSZT7ENXVE87BF57XMX/files/audit"
}
}
}In files trovi gli indirizzi dell'API, che richiedono la tua chiave: il webhook non contiene mai link
diretti ai documenti. Per signer.signed, data.signer_id indica chi ha firmato.
Verificare la firma
Verifica sempre la firma prima di fidarti del contenuto. v1 è l'HMAC-SHA256, con il secret
dell'endpoint, di t + . + il corpo della richiesta così come lo ricevi, byte per byte. Rifiuta le
richieste con t più vecchio di 5 minuti: impedisce di rigiocare una richiesta intercettata.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verifyNuvaSign(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map((part) => part.split('=')))
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
return v1?.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}Con Express usa express.raw({ type: 'application/json' }) su quella rotta: se il corpo viene prima
convertito in oggetto e poi riserializzato, la firma non torna.
Consegna e ritentativi
- Rispondi con un
2xxentro 10 secondi. Qualunque altra risposta, un timeout o un redirect contano come fallimento. - Dopo un fallimento NuvaSign ritenta dopo 1 minuto, 5 minuti, 30 minuti, 2 ore, 6 ore e 24 ore. Dopo
l'ultimo tentativo la consegna è
FAILEDe puoi rinviarla dalla console. - Ogni tentativo manda lo stesso corpo; cambia solo
tnella firma. UsaX-NuvaSign-Deliveryper riconoscere i duplicati: in casi rari lo stesso evento può arrivare due volte. - Gli endpoint devono essere
httpse raggiungibili da Internet: indirizzi di rete interna non sono ammessi.
Eventi via polling
GET /v1/events?since=2026-09-15T00:00:00Z&cursor=...&limit=50Scope signature_requests:read. Alternativa ai webhook se preferisci interrogare: gli stessi eventi, con lo
stesso data, dal più vecchio al più recente.
| Parametro | Descrizione |
|---|---|
since |
solo eventi da questo istante (ISO 8601) |
cursor |
il next_cursor della pagina precedente |
limit |
1–100, default 50 |
{
"data": [
{ "id": "597", "event": "signature_request.completed", "occurred_at": "2026-09-15T12:00:00.000Z", "data": { "id": "01M2JA0..." } }
],
"next_cursor": "597"
}Salva l'id dell'ultimo evento elaborato e riparti da lì come cursor: nessun evento si perde fra una
chiamata e l'altra. data riporta lo stato attuale della richiesta, non quello al momento dell'evento.
Schema JSON dei parametri
Generato dagli stessi schemi con cui l'API valida le richieste: non può divergere dal comportamento reale.
Descrive la forma dei dati; i vincoli fra campi (per esempio il telefono obbligatorio con otp: "sms")
sono nelle tabelle sopra.
Corpo di POST /v1/signature-requests
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"document": {
"type": "object",
"properties": {
"base64": {
"type": "string",
"minLength": 1,
"maxLength": 13981018
},
"url": {
"type": "string",
"format": "uri"
},
"template_id": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
}
},
"signers": {
"minItems": 1,
"maxItems": 20,
"type": "array",
"items": {
"type": "object",
"properties": {
"first_name": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"last_name": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"email": {
"type": "string",
"maxLength": 255,
"format": "email",
"pattern": "^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
},
"phone": {
"type": "string",
"pattern": "^\\+[1-9]\\d{6,14}$"
},
"otp": {
"type": "string",
"enum": ["sms", "email"]
}
},
"required": ["first_name", "last_name", "otp"]
}
},
"delivery": {
"default": "none",
"type": "string",
"enum": ["none", "sms", "email"]
},
"with_timestamp": {
"default": true,
"type": "boolean"
},
"expires_in_hours": {
"default": 72,
"type": "integer",
"minimum": 1,
"maximum": 168
},
"external_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"metadata": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
},
"required": ["document", "signers"]
}Query di GET /v1/signature-requests
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"external_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"status": {
"type": "string",
"enum": ["pending", "processing", "completed", "expired", "declined", "error", "canceled"]
},
"cursor": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"limit": {
"default": 25,
"type": "integer",
"minimum": 1,
"maximum": 100
}
}
}