Hand out signing links yourself

By default Circularo e-mails every recipient an invitation. When your own application should deliver the link instead, ask for the recipient's direct link and pass it on however you like. It takes two calls, plus one rule worth knowing before you start.

Before you begin

  • Sending to people outside your organization must be enabled for your account. Check settings.canShareWithExternalUsers in GET /me — when it is off, a recipient without a Circularo account is rejected with 403.

  • The verification method you choose needs a right of its own. This guide protects the recipient with an e-mail code, which requires share_mail_otp; without it the transaction call returns 403.

Important

A direct link can only be issued for a recipient whose access is protected — by identity verification, or by signing in when they have a Circularo account. Ask for a link to an unprotected recipient and the call is refused with 409, so decide on the protection when you send the document, not afterwards.

Circularo's own e-mail is the right choice when the recipient only needs to be asked to sign. Delivering the link yourself is worth the extra call when the signature is part of a journey your application already owns:

  • Checkout and onboarding — the customer signs the terms in the flow they are already in, without leaving for their inbox.

  • Portals — an employee or client portal lists the documents waiting for the person who is signed in, each linking straight to its signing page.

  • Your own delivery channel — the recipient is reached over SMS, chat or a printed QR code rather than e-mail.

The link opens the Circularo web app, so the recipient still gets the full signing experience — and any verification you configured still stands between the link and the document.

Step 1: Send the document with the recipient's access protected

Configure the recipient's verification as part of sending. Here the recipient receives a one-time code by e-mail:

POST /transactions
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{
    "document": {
        "title": "Employment Agreement",
        "fileContentBase64": "JVBERi0xLjQKJeLjz9MK..."
    },
    "send": {
        "recipients": [
            {
                "recipient": "jane.doe@example.com",
                "purpose": "sign",
                "verification": {
                    "email": {}
                }
            }
        ],
        "signatureFields": [
            {
                "recipients": [
                    "jane.doe@example.com"
                ],
                "pages": [
                    1
                ],
                "position": {
                    "x": 0.15,
                    "y": 0.6,
                    "width": 0.25,
                    "height": 0.06
                }
            }
        ]
    }
}

An empty email object sends the code to the recipient's own address; Verify recipient identity covers the other methods.

HTTP/2 201

{
    "id": "Vt7RfKq2LmXe5ZwGyB4N",
    "recipients": [
        {
            "completedAt": null,
            "expiresAt": null,
            "id": "Gk5cPdM9XvT3qHnU7jWs",
            "isMandatory": true,
            "language": "en",
            "message": null,
            "order": null,
            "permissions": [
                "print",
                "viewMetadata",
                "annotateOnReject",
                "rejectWithoutComment",
                "uploadFiles"
            ],
            "purpose": "sign",
            "quorum": null,
            "recipient": "jane.doe@example.com",
            "reminder": null,
            "status": "pending",
            "turnStartedAt": "2026-05-11T09:30:00.000Z",
            "verification": {
                "email": {
                    "address": "j*****e@example.com"
                },
                "kyc": null,
                "nafath": null,
                "oauth": null,
                "password": null,
                "sms": null
            }
        }
    ],
    ...
}

Keep the recipient's id — the link belongs to a recipient, not to the transaction. Their verification comes back with the address anonymized.

POST /transactions/Vt7RfKq2LmXe5ZwGyB4N/recipients/Gk5cPdM9XvT3qHnU7jWs/link
Authorization: Bearer YOUR_API_KEY
HTTP/2 201

{
    "shareToken": "82c5fa30d26bc80b828c2c6e8ced7f2655b0f1539a25a8ac888ccc75b30363f0",
    "shareUrl": "https://sandbox.circularo.com/share?shareToken=82c5fa30d26bc80b828c2c6e8ced7f2655b0f1539a25a8ac888ccc75b30363f0"
}

shareUrl is ready to hand over as it is; shareToken is the same access, raw, for when you build the URL into your own interface. Asking again returns the same link, so a retry never invalidates a link you already delivered.

Warning

Treat the link as a credential and deliver it over a channel you trust. It reaches the recipient's action directly, and the verification you configured is what stands between the link and the document.

Step 3: Know when verification freezes

Issuing the link locks the recipient's verification. Changing it afterwards is refused:

PATCH /transactions/Vt7RfKq2LmXe5ZwGyB4N/recipients/Gk5cPdM9XvT3qHnU7jWs
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{
    "verification": {
        "email": {
            "address": "jane.doe+agreements@example.com"
        }
    }
}
HTTP/2 409

{
    "status": 409,
    "type": "SHARE:STATE_CONFLICT",
    "message": "Protection can't be modified or disabled after share link was generated.",
    "requestId": "f4f0b7a6cf2f4c4d8c2f3a4b5c6d7e8f"
}

Set up verification the way you want it before you create the link. Everything else about the recipient — the deadline, reminders, permissions — can still be changed.

Note

Issuing a link is recorded in your organization's audit log, together with who issued it and when.

Complete example

Sending a document and handing the link to your own interface, as a Node.js script. Error handling is minimal for brevity — in production, inspect the error envelope of non-2xx responses.

JavaScript
import fs from "node:fs/promises";

const BASE_URL = "https://sandbox.circularo.com/api/v1/public"; // your instance's base URL
const API_KEY = "YOUR_API_KEY";
const AUTH_HEADER = { "Authorization": `Bearer ${API_KEY}` };

const pdfBase64 = (await fs.readFile("employment-agreement.pdf")).toString("base64");

const createRes = await fetch(`${BASE_URL}/transactions`, {
    method: "POST",
    headers: { ...AUTH_HEADER, "Content-Type": "application/json" },
    body: JSON.stringify({
        document: {
            title: "Employment Agreement",
            fileContentBase64: pdfBase64
        },
        send: {
            recipients: [
                {
                    recipient: "jane.doe@example.com",
                    purpose: "sign",
                    verification: {
                        email: {}
                    }
                }
            ],
            signatureFields: [
                {
                    recipients: [
                        "jane.doe@example.com"
                    ],
                    pages: [
                        1
                    ],
                    position: {
                        x: 0.15,
                        y: 0.6,
                        width: 0.25,
                        height: 0.06
                    }
                }
            ]
        }
    })
});
if (!createRes.ok) throw new Error(`Transaction failed: ${createRes.status}`); // error handling kept minimal for brevity

const transaction = await createRes.json();
const recipient = transaction.recipients[0];

const linkRes = await fetch(`${BASE_URL}/transactions/${transaction.id}/recipients/${recipient.id}/link`, {
    method: "POST",
    headers: AUTH_HEADER
});
const { shareUrl } = await linkRes.json();

// hand the link to your own interface instead of waiting for the recipient to open their inbox
console.log(`Send ${recipient.recipient} to ${shareUrl}`);

Next steps