Quick start
Base URL: https://tidvis.se/api/public/v1. All calls use JSON. The Public API is part of the Enterprise plan.
- 1. Create an API client under Sign → Settings → API and save your
client_idandclient_secret. - 2. Exchange them for an
access_tokenvia OAuth2 client credentials. - 3. Create an agreement, upload a PDF, add participants and send.
curl -X POST https://tidvis.se/api/public/v1/oauth/access-token \
-H "Content-Type: application/json" \
-d '{"client_id":"tvc_...","client_secret":"tvs_..."}'One unified model — API and platform share the same agreement
Agreements created via the API live in the same table and share the entire infrastructure with agreements created in the web UI:
- Visible in the platform under the Via integration tab in
/app/sign/dokument. - Can be opened, patched, reminded and cancelled from both API and UI.
- Same auto-reminder cron, archive and 18-month retention as native agreements.
- Participant signing URL is always
https://tidvis.se/sign/<token>— same flow as agreements created in the platform. - UI shows a Via {external_source} badge (clickable when
external_urlis set) so admins can jump back to the CRM record. - Pass
senderin the request and the sender is auto-signed onsend— same behavior as the platform UI.
Authentication
OAuth2 client credentials. Exchange client_id+client_secret for a JWT valid for 1 hour. Send Authorization: Bearer <access_token> on every subsequent call.
/oauth/access-tokenIssue an access token (1h TTL).
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": "2026-06-07T13:00:00.000Z"
}/oauth/access-tokenRevoke the current token (requires Authorization header).
Agreements
/agreementsCreate a draft agreement (status: draft).
curl -X POST https://tidvis.se/api/public/v1/agreements \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Employment contract Anna","expires_in_days":14,"bankid_required":true}'Body fields (rendered from the OpenAPI spec)
| Field | Type | Description |
|---|---|---|
titlerequired | string | — |
message | string | — |
expires_at | string (date-time) | — |
expires_in_days | integer | Alternativ till expires_at — sätter expires_at till nu + N dagar. expires_at vinner om båda skickas. |
callback_url | string (uri) | Äldre synonym till `webhook_url`. Används bara om `webhook_url` saknas. Nya integrationer ska använda `webhook_url`. |
webhook_url | string (uri) | Per-avtal webhook-URL (https). Sätts den skickas alla webhook-händelser för just detta avtal hit i stället för till klientens registrerade URL — använd när varje kundmiljö har sin egen mottagare (t.ex. https://kund.tidvis.se/dokument/signera/tidvissign/status/). Ingen förhandsregistrerad webhook krävs — adressen fungerar fristående och får alla händelsetyper för avtalet. Utskicket signeras med klientens signeringsnyckel (X-Tidvis-Signature). Alternativt kan händelser hämtas med GET /events.(nullable) |
metadata | object | — |
bankid_required | boolean | — |
bankid_identify_required | boolean | — |
document_source | "sender" | "recipient" | 'sender' (default) = avsändaren laddar upp PDF via PUT /documents/main. 'recipient' = mottagaren laddar upp PDF/PNG/JPG via publik dropzone; kräver exakt 1 recipient. |
external_id | string | — |
external_source | string | — |
external_url | string (uri) | — |
completed_delivery | "email_attachment" | "secure_link" | Leveranssätt för det signerade avtalet. `email_attachment` (standard): PDF bifogas i klart-mejlet. `secure_link`: ingen bilaga — mottagaren får en länk, identifierar sig med BankID och servern kontrollerar att personnumret tillhör en behörig mottagare innan en personlig nedladdning (giltig 10 min) skapas. Länken gäller 30 dagar efter att avtalet blev klart. Kräver BankID-tillvalet (402 `bankid_disabled`) och att varje signerare är knuten till ett personnummer: `bankid_required: true` eller `signer_personal_number` (annars 400 `personal_number_required_for_secure_delivery` vid utskick). Har kontot "Kräv alltid säker leverans" påslaget avvisas `email_attachment` med 409 `secure_delivery_enforced`. Utelämnas fältet gäller kontots standard. Ingen automatisk nedgradering till bilaga sker någonsin. Obs: BankID ensamt gör inte behandlingen GDPR-kompatibel — kunden ansvarar fortfarande för sin riskbedömning. |
completed_copy_recipients | array[object] (max 5) | Kopiemottagare som får hämta avtalet vid säker leverans. Personnumret används för behörighetskontrollen och lagras bara som hash. Vid `secure_link` får adresser i `completed_copy_emails` utan personnummer bara ett meddelande om att avtalet är klart — ingen länk. |
completed_copy_emails | array[email] (max 5) | Upp till 5 mejladresser som får bekräftelsemejlet med signerad PDF bifogad när avtalet blir completed. Utelämnas fältet ärvas kontots inställning. |
sender | object | Avsändare — autosigneras vid send (samma beteende som UI-flödet). Läggs till som is_sender=true och skickar inget signeringsmail. |
sender.name | string | — |
sender.emailrequired | string (email) | — |
auto_reminders | object | Automatic reminder settings (defaults: enabled=true, interval_days=3, max_reminders=3). Reminders run daily via cron; requires the account-level auto-reminder toggle to also be enabled. |
auto_reminders.enabled | boolean | — |
auto_reminders.interval_days | integer | — |
auto_reminders.max_reminders | integer | — |
/agreementsList agreements. Supports ?status, ?limit, ?cursor.
/agreements/:idFetch an agreement with participants and status. Also the status-polling endpoint — see below.
Response fields — Agreement (rendered from the OpenAPI spec)
| Field | Type | Description |
|---|---|---|
id | string (uuid) | — |
status | "draft" | "sent" | "partially_signed" | "signed" | "completed" | "cancelled" | "declined" | "expired" | — |
title | string | — |
message | string | —(nullable) |
expires_at | string (date-time) | —(nullable) |
parties | array[object] | — |
external_id | string | —(nullable) |
external_source | string | —(nullable) |
external_url | string (uri) | —(nullable) |
metadata | object | —(nullable) |
version | integer | Versionsnummer i kedjan (1 = första). |
replaces_document_id | string (uuid) | Pekar på det avtal som denna version ersätter.(nullable) |
superseded_at | string (date-time) | Sätts när avtalet ersatts av en nyare version.(nullable) |
versions | array[object] | Hela versionskedjan (endast med i responsen när avtalet ingår i en kedja, dvs `version > 1` eller är superseded).(nullable) |
auto_reminders_enabled | boolean | — |
reminder_interval_days | integer | — |
max_reminders | integer | — |
webhook_url | string (uri) | Avtalets egen webhook-URL. null = klientens registrerade webhook-URL används (eller `callback_url` om den är satt på avtalet).(nullable) |
completed_copy_emails | array[email] (max 5) | Mejladresser som får en kopia av bekräftelsemejlet med den signerade PDF:en bifogad när avtalet blir completed. null = ärver kontots inställning (Inställningar i appen).(nullable) |
completed_delivery | "email_attachment" | "secure_link" | Leveranssätt satt på avtalet. null = kontots standard gäller.(nullable) |
signed_pdf_url | string (uri) | Short-lived signed URL to the stamped final PDF (only present after completion). Alltid null vid säker leverans (`secure_link`) — hämta då filen med API-nyckeln via `GET /agreements/{id}/signed-pdf`.(nullable) |
signed_at | string (date-time) | —(nullable) |
signed_sha256 | string | Hex SHA-256 of the stamped final PDF.(nullable) |
created_at | string (date-time) | — |
updated_at | string (date-time) | — |
/agreements/:id/eventsAudit trail (sent, viewed, signed, completed...).
/agreements/:id/downloadFetch the signed PDF (returns a signed URL or binary content).
Copies of signed agreements (completed_copy_emails)
Pass completed_copy_emails (up to 5 addresses) when creating an agreement via POST /agreements or quick-send. Each address receives the confirmation email with the signed PDF attached once the agreement completes. If omitted (null), the account-level setting under Settings is inherited. The field is returned on GET /agreements/:id — see the field tables above.
Poll signing status
Tidvis Sign has no separate /status resource — GET /agreements/:id is the polling endpoint. The response includes the agreement status and participants[].status + signed_at per party, so you can render "1 of 2 signed" in your own UI without waiting for a webhook.
Recommendation: use webhooks (agreement.viewed, agreement.signed, agreement.completed) as the primary channel and fall back to polling at a 30 s interval when webhooks aren't available. Agreement status values: draft, sent, partially_signed, completed, declined, cancelled.
# Poll status and count signers
curl -s https://tidvis.se/api/public/v1/agreements/$ID \
-H "Authorization: Bearer $TOKEN" \
| jq '{
status,
signed: ([.participants[] | select(.status == "signed")] | length),
total: (.participants | length),
participants: [.participants[] | {name, email, status, signed_at}]
}'Sample response (1 of 2 signed):
{
"status": "partially_signed",
"signed": 1,
"total": 2,
"participants": [
{ "name": "Anna Andersson", "email": "anna@example.com", "status": "signed", "signed_at": "2026-06-17T09:14:22Z" },
{ "name": "Erik Eriksson", "email": "erik@example.com", "status": "viewed", "signed_at": null }
]
}Participants
/agreements/:id/participantsAdd a signer (only in draft state).
{
"name": "Anna Andersson",
"email": "anna@example.com",
"role": "signer",
"job_title": "CEO",
"phone": "+46701234567",
"personal_id_masked": "19800101-XXXX",
"notifications_enabled": true
}/agreements/:id/participants/:pid/declineDecline the agreement for a specific participant. Sets agreement status to 'declined'.
{ "reason": "Terms not acceptable" }/agreements/:id/participants/:pid/delegateDelegate signing to a new person. The original participant is marked 'delegated'.
{
"name": "Erik Eriksson",
"email": "erik@example.com",
"job_title": "CFO",
"phone": "+46707654321",
"message": "Forwarding to Erik who handles signing."
}/agreements/:id/participants/:pid/notificationsEnable or disable reminder emails for a single participant.
{ "enabled": false }/agreements/:id/chatList chat messages exchanged between the agent and participants on an agreement.
/agreements/:id/chatPost a chat message. Pass participant_id to relay a message from a participant. Fires the agreement.chat_message webhook.
{
"body": "Hi, could you review appendix 2?",
"sender_label": "Tidvis Sign Agent",
"participant_id": "8a3..."
}PDF documents
/agreements/:id/documents/mainUpload the main document. Accepts application/pdf (binary) or JSON with base64.
# JSON / base64
curl -X PUT https://tidvis.se/api/public/v1/agreements/$ID/documents/main \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename":"agreement.pdf","content_base64":"JVBERi0..."}'
# Or binary
curl -X PUT https://tidvis.se/api/public/v1/agreements/$ID/documents/main \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/pdf" \
--data-binary @agreement.pdfLifecycle
/agreements/:id/lifecycleSend or cancel an agreement.
{"action": "send"} // or "cancel"Email OTP (default method)
Every agreement is signed by default using email + one-time code (OTP). No extra configuration required – when bankid_required is false or omitted, each participant uses this method. Counts as a simple electronic signature (SES) under eIDAS.
How it works
- The recipient receives a personal link to their email (the address in
participants[].email). - A 6-digit one-time code is sent on document open, valid for 15 minutes, max 5 attempts.
- Identity is verified before the signature is recorded; IP, user agent, timestamp and code verification are stored in the audit trail.
- Included on every plan – no add-on or per-signature fee.
Create an OTP agreement (default)
curl -X POST https://tidvis.se/api/public/v1/agreements \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Consulting agreement Anna",
"expires_in_days": 14
}'Webhook payload for OTP signatures
{
"event": "agreement.signed",
"agreement_id": "agr_...",
"participant": { "id": "p_...", "email": "anna@example.com" },
"signature": {
"method": "otp_email",
"signer_name": "Anna Andersson",
"email": "anna@example.com",
"code_verified_at": "2026-06-11T08:41:53.000Z",
"signed_at": "2026-06-11T08:42:11.000Z",
"ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 ..."
}
}Lock BankID to a specific personal number
Set signer_personal_number on a recipient to require that exactly that person identifies and/or signs with BankID. Anyone else is rejected inside the BankID app. Requires the agreement to have bankid_required: true and/or bankid_identify_required: true. The field is write-only — it is never returned in responses; use personal_id_masked (e.g. 19800101-XXXX) for display.
{
"title": "Employment contract Anna",
"bankid_required": true,
"participants": [
{
"name": "Anna Andersson",
"email": "anna@example.com",
"signer_personal_number": "198001011234"
}
]
}Accepted format: 10 or 12 digits with an optional dash (e.g. 198001011234, 800101-1234). Luhn-validated. Sending a personal number without BankID enabled on the agreement, or a number that fails Luhn, returns 400 invalid_request.
BankID
Alternative to the default Email OTP method when you need advanced electronic signatures (AES). Set bankid_required: true on creation (or via PATCH before sending). When the agreement is sent, each participant signs with BankID on mobile or desktop instead of entering a one-time code.
Requirements
- The account must have the Sign Pro plan and the BankID add-on enabled.
- Pricing: 100 BankID signatures included per month, then 5 SEK/signature.
- Without the add-on,
POST /agreements/:id/lifecyclewithaction: "send"returns402 bankid_disabled.
Create a BankID agreement
curl -X POST https://tidvis.se/api/public/v1/agreements \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Employment contract Anna",
"bankid_required": true
}'Response (abbreviated):
{
"id": "agr_...",
"status": "draft",
"bankid_required": true,
"document": { "uploaded": false, "sha256": null },
"participants": []
}Webhook payload when BankID is used
For agreements with bankid_required: true, the agreement.signed event is enriched with a signature block. For privacy reasons, neither personal identity numbers, certificates nor OCSP responses are exposed via the API or webhooks.
{
"event": "agreement.signed",
"agreement_id": "agr_...",
"participant": { "id": "p_...", "email": "anna@example.com" },
"signature": {
"method": "bankid",
"signer_name": "Anna Andersson",
"signed_at": "2026-06-11T08:42:11.000Z"
}
}The signature.method field is always one of "otp_email" or "bankid". Agreements with mixed participants log the method per signer.
BankID identification before opening
Optional gate that forces the recipient to identify with BankID before the document is shown. It is independent of the chosen signature method — you can require BankID identification and still let the recipient sign with Email OTP, click-to-sign, or drawn signature. Combine with bankid_required: true when you want both identification before opening and BankID for the actual signature.
Enable on creation
curl -X POST https://tidvis.se/api/public/v1/agreements \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "NDA Anna",
"bankid_identify_required": true
}'Response (abbreviated):
{
"id": "agr_...",
"status": "draft",
"bankid_required": false,
"bankid_identify_required": true,
"participants": []
}Recipient behavior
- When the recipient opens the link, a BankID gate is shown instead of the PDF.
- They must start BankID on mobile or via QR code and confirm the identification.
- Only then is the PDF rendered and the chosen signature method enabled.
- The "viewed" event is logged after identification, not on the first click.
Requirements and pricing
- Account must have Sign Pro with the BankID add-on — same prerequisites as
bankid_required. - Identifications do not count against the 100 BankID signatures/month quota. Only actual BankID signatures (
bankid_required: true) are billed at 5 SEK each beyond the included amount. - Without the add-on,
POST /agreements/:id/lifecyclewithaction: "send"returns402 bankid_disabled.
Webhook payload
A dedicated agreement.identified event is delivered when a recipient identifies. Personal identity numbers, certificates and OCSP responses are never exposed via the API.
{
"event": "agreement.identified",
"agreement_id": "agr_...",
"participant": { "id": "p_...", "email": "anna@example.com" },
"identification": {
"method": "bankid",
"signer_name": "Anna Andersson",
"identified_at": "2026-06-12T10:21:04.000Z"
}
}Agreement generator
Build an AI-driven agreement template from 2–10 example contracts and then generate new agreements with a single call. Generated agreements can be downloaded as DOCX or sent straight to signing via Sign — in which case you get back a sign_document_id.
Requirements & pricing
- The agreement module must be active (99 SEK/user/month). Otherwise the endpoint returns
402 plan_required. - MCP/agent calls add 3 SEK per generation on top of the module fee.
- Source files max 20 MB each, 2–10 files per template (PDF or DOCX).
Scopes
templates:read— list and fetch templatestemplates:manage— create and delete templatesgenerations:create— generate new agreements from a templategenerations:read— list, fetch and download generated agreements- If
signature_method: "bankid"is used ingenerate,agreements:sendis also required.
/agreement-templatesList saved agreement templates.
/agreement-templatesCreate a template from 2–10 example agreements. Multipart (files[]) or JSON with base64 (sources[]).
curl -X POST https://tidvis.se/api/public/v1/agreement-templates \
-H "Authorization: Bearer $TOKEN" \
-F "name=Consulting agreement" \
-F "files=@sample1.pdf" \
-F "files=@sample2.pdf" \
-F "files=@sample3.docx"/agreement-templates/:idFetch a template schema (fields, blocks, sources).
/agreement-templates/:idPermanently delete a template.
/agreement-templates/:id/generateGenerate a new agreement from a template. Returns DOCX inline (default) or a sent Sign draft when send_for_signing=true and recipients are provided.
{
"values": {
"customer_name": "Acme Ltd",
"amount": 45000,
"start_date": "2026-08-01"
},
"send_for_signing": true,
"signature_method": "bankid",
"recipients": [
{ "name": "Anna Andersson", "email": "anna@acme.com" }
]
}/agreement-generations?template_id=...List generated agreements (optionally filter by template_id).
/agreement-generations/:idFetch metadata for a generation.
/agreement-generations/:id/downloadSigned download URL (10-minute TTL) for the generated PDF/DOCX.
Full request/response schemas live in the OpenAPI reference.
Webhooks
Tidvis delivers events to your URL via POST JSON. Every request is HMAC-SHA256 signed in the header X-Tidvis-Signature: sha256=<hex> based on your signing_secret and the raw body. Failed deliveries are retried with exponential backoff for up to 24h.
To route different agreements to different receivers (for example one URL per customer environment), send webhook_url when creating the agreement — every event for that agreement is then delivered there instead of to the registered URL. No pre-registered webhook is required — the address works on its own and receives every event for that agreement, signed with the client signing secret. Alternatively, poll GET /events.
/webhooksRegister a webhook.
{
"client_id": "<api_client uuid>",
"url": "https://your-app.com/webhooks/tidvis",
"events": ["agreement.sent","agreement.signed","agreement.completed"]
}/webhooksList registered webhooks.
/webhooks/:idRemove a webhook.
Events
agreement.sentagreement.viewedagreement.signedagreement.completedagreement.cancelledagreement.declinedagreement.delegatedparticipant.notifications_changedagreement.chat_messageagreement.expired
For agreements with bankid_required: true, the agreement.signed/agreement.completed events include a signature block. See BankID.
Every delivery carries an event_id in the body. The same event always has the same event_id — use it to ignore repeats. agreement.completed also includes participants and document.signed_pdf_url (fetch the PDF with your API key).
Verify signature (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const given = (header || "").replace(/^sha256=/, "");
return timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}Error codes
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed input. |
| 401 | unauthorized | Missing or invalid token. |
| 402 | plan_required | Account lacks the Enterprise plan. Includes upgrade_url. |
| 402 | bankid_disabled | Agreement has bankid_required: true but the account lacks the BankID add-on. |
| 403 | forbidden | No permission for the resource. |
| 404 | not_found | Resource not found. |
| 409 | conflict | Invalid state transition (e.g. sending an already sent agreement). |
| 429 | rate_limited | Too many requests. |
| 500 | server_error | Server error – retry. |
Rate limits
Default: 60 requests/minute per client and 600/hour for uploads. When exceeded you'll get 429 rate_limited with a Retry-After header. Need more? Contact us.
MCP & AI agents
Tidvis Sign exposes a Model Context Protocol server so AI agents (ChatGPT, Claude Desktop, custom agents) can create, send and track agreements with a single tool call. Discovery manifest at /.well-known/mcp.json. A dedicated landing page lives at /en/developers/mcp.
Endpoint
POST https://tidvis.se/api/mcp
Authorization: Bearer <access_token>
Content-Type: application/json
Accept: application/json, text/event-streamScopes
API tokens can be restricted to specific scopes. MCP access requires mcp:connect plus any combination of:
agreements:read– fetch agreements & eventsagreements:create– create drafts & participantsagreements:send– send agreements for signingagreements:cancel– cancel agreementsbilling:checkout– generate checkout linktemplates:read/templates:manage– manage agreement templatesgenerations:create/generations:read– generate & read agreements from a templatemcp:connect– required for MCP access
Available tools
tidvis_sign_create_agreementtidvis_sign_add_participanttidvis_sign_upload_pdftidvis_sign_send_agreementtidvis_sign_cancel_agreementtidvis_sign_get_agreementtidvis_sign_list_eventstidvis_sign_create_checkouttidvis_sign_decline_agreementtidvis_sign_delegate_signingtidvis_sign_set_participant_notificationstidvis_sign_list_chat_messagestidvis_sign_post_chat_messagetidvis_sign_list_agreement_templatestidvis_sign_get_agreement_templatetidvis_sign_create_agreement_templatetidvis_sign_generate_agreementtidvis_sign_list_agreement_generationstidvis_sign_get_agreement_generation
Claude Desktop configuration
{
"mcpServers": {
"tidvis-sign": {
"url": "https://tidvis.se/api/mcp",
"headers": { "Authorization": "Bearer YOUR_ACCESS_TOKEN" }
}
}
}Agent checkout & pay-per-agreement
Two ways for an agent to let the end customer pay:
- Sign Pro (subscription) – monthly fee per seat, unlimited agreements. Use
tidvis_sign_create_checkoutwithproduct: "sign_pro_monthly"and the desired number of seats. - Pay-per-agreement – 19 SEK/agreement as prepaid credits, ideal for low-volume agents. Use
product: "sign_per_agreement"and a credit count (1–500). Credits are consumed automatically whensend_agreementruns on free-plan accounts.
The checkout tool returns a Stripe-hosted URL the agent shows to the end customer. A 402 plan_required on send_agreement means the account has neither credits nor Sign Pro – the response includes an upgrade_url the agent can link to.