Vai al contenuto
← API di firma elettronica

Documentazione API di firma elettronica

Tutto quello che serve per integrare la firma elettronica di NuvaSign nel tuo software: chiavi, richieste di firma, stati, webhook ed errori. Per provarla senza costi crea una chiave di prova dalla console.

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.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy

La 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.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy

Non è 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 nessuno
Sigillo e marca temporale 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-Remaining e RateLimit-Reset; oltre il limite si riceve 429 con Retry-After in secondi. RateLimit-Limit riporta 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_progress con Retry-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-requests

Scope 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 punti

Il 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_timestamp resta 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 documenti

Una 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}/cancel

Scope signature_requests:write. Solo per richieste pending: altrimenti 409. I link di firma smettono subito di funzionare. Nessun rimborso.

Crediti

GET /v1/credits

Scope 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/audit

Scope 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 (livello PAdES-BASELINE-LT): si applica sempre, qualunque cosa dica with_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 2xx entro 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 è FAILED e puoi rinviarla dalla console.
  • Ogni tentativo manda lo stesso corpo; cambia solo t nella firma. Usa X-NuvaSign-Delivery per riconoscere i duplicati: in casi rari lo stesso evento può arrivare due volte.
  • Gli endpoint devono essere https e raggiungibili da Internet: indirizzi di rete interna non sono ammessi.

Eventi via polling

GET /v1/events?since=2026-09-15T00:00:00Z&cursor=...&limit=50

Scope 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
    }
  }
}