Run a complete agreement end to end

The earlier pages each covered one part of sending a document for signature. This one puts them together: you sign the agreement yourself, a new hire with no Circularo account signs it through a link and fills in their job title, either of two managers approves, and HR countersigns. Six calls take it from a PDF to a signed file on your disk.

Before you begin

  • You have read the rest of this chapter. Everything here is explained in detail on the page it belongs to; this page shows how the pieces combine.

  • Your signature image is configured in the Circularo web app (Sign a document yourself), and applying it needs use_sign on your own account.

  • Two things the recipients below depend on: sending to somebody without a Circularo account requires that your account may share outside the organization (settings.canShareWithExternalUsers in GET /me), and the e-mail code protecting the new hire needs the share_mail_otp right.

Step 1: Send the agreement and sign it in the same call

One request creates the document, applies your own signature to it, and routes it through everybody else:

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

{
    "document": {
        "title": "Employment Agreement",
        "fileContentBase64": "JVBERi0xLjQKJeLjAM/T..."
    },
    "apply": {
        "signatures": [
            {
                "fileId": "da09a47b4f5b1xv726cfm36l8k1hkxzwmj5mszvfq6szoambdvc44v6i8hrhnu1f",
                "pages": [
                    1
                ],
                "position": {
                    "x": 0.6,
                    "y": 0.66,
                    "width": 0.25,
                    "height": 0.06
                }
            }
        ]
    },
    "send": {
        "sequential": true,
        "message": "Please review and sign the renewed employment agreement.",
        "recipients": [
            {
                "recipient": "jane.doe@example.com",
                "purpose": "sign",
                "order": 1,
                "verification": {
                    "email": {}
                }
            },
            {
                "recipient": "robert.brown@example.com",
                "purpose": "approve",
                "order": 2,
                "quorum": 1
            },
            {
                "recipient": "emma.wilson@example.com",
                "purpose": "approve",
                "order": 2,
                "quorum": 1
            },
            {
                "recipient": "alex.novak@example.com",
                "purpose": "sign",
                "order": 3
            }
        ],
        "signatureFields": [
            {
                "recipients": [
                    "jane.doe@example.com"
                ],
                "pages": [
                    1
                ],
                "position": {
                    "x": 0.12,
                    "y": 0.66,
                    "width": 0.25,
                    "height": 0.06
                }
            },
            {
                "recipients": [
                    "alex.novak@example.com"
                ],
                "pages": [
                    1
                ],
                "position": {
                    "x": 0.6,
                    "y": 0.78,
                    "width": 0.25,
                    "height": 0.06
                }
            }
        ],
        "annotationFields": [
            {
                "recipients": [
                    "jane.doe@example.com"
                ],
                "pages": [
                    1
                ],
                "tooltip": "Enter your job title",
                "position": {
                    "x": 0.12,
                    "y": 0.78,
                    "width": 0.3,
                    "height": 0.04
                }
            }
        ]
    }
}

Four things are happening at once here:

  • apply puts your signature on the document straight away, before anybody else sees it.

  • send.recipients act in rounds because sequential is true. The new hire signs in round 1, the two managers share round 2 with a quorum of 1 — whichever of them approves first settles the round for both — and HR countersigns in round 3.

  • The new hire has no Circularo account, so their access is protected by a one-time code sent to their address. That protection is what lets you hand them a link in the next step.

  • signatureFields gives the new hire and HR a signature box each, on their own line of the agreement, and annotationFields adds a form field where the new hire types their job title. Everyone who has to sign knows exactly where.

HTTP/2 201

{
    "id": "Vt7RfKq2LmXe5ZwGyB4N",
    "status": "in-progress",
    "document": {
        "id": "Dq4RfKp2LmXe5ZwGyB4N",
        "mainFileId": "b71f0c9de2a34c7f8de51a2c6b90f4e37c18a5d0429e6bb3f1c7a8d25e93b60a4",
        "metadataDefinition": "d_default",
        "signatureProvider": "internal",
        "templateId": null,
        "title": "Employment Agreement"
    },
    "recipients": [
        {
            "id": "Gk5cPdM9XvT3qHnU7jWs",
            "recipient": "jane.doe@example.com",
            "purpose": "sign",
            "order": 1,
            "quorum": null,
            "status": "pending",
            ...
        },
        {
            "id": "Wm5kBq2vNp9xLc4HtYeD",
            "recipient": "emma.wilson@example.com",
            "purpose": "approve",
            "order": 2,
            "quorum": 1,
            "status": "queued",
            ...
        },
        {
            "id": "Rj8dGt3yQs6nZb1HxKmA",
            "recipient": "robert.brown@example.com",
            "purpose": "approve",
            "order": 2,
            "quorum": 1,
            "status": "queued",
            ...
        },
        {
            "id": "Lp2WdN8qYt5vHmC4bRfZ",
            "recipient": "alex.novak@example.com",
            "purpose": "sign",
            "order": 3,
            "quorum": null,
            "status": "queued",
            ...
        },
        {
            "id": null,
            "recipient": "john.smith@example.com",
            "purpose": "sign",
            "order": null,
            "quorum": null,
            "status": "completed",
            ...
        }
    ],
    ...
}

You appear among the recipients as an already completed signer — your signature is on the document, but the transaction runs on for everybody else. The new hire is pending; the rest are queued behind them.

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

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

Deliver shareUrl however suits your onboarding — the code they receive by e-mail is what proves it is them.

Step 3: Watch the rounds advance

The new hire signs and fills their field. Reading the transaction shows what that did:

GET /transactions/Vt7RfKq2LmXe5ZwGyB4N
Authorization: Bearer YOUR_API_KEY
HTTP/2 200

{
    "id": "Vt7RfKq2LmXe5ZwGyB4N",
    "status": "in-progress",
    "recipients": [
        {
            "recipient": "jane.doe@example.com",
            "order": 1,
            "status": "completed",
            ...
        },
        {
            "recipient": "emma.wilson@example.com",
            "order": 2,
            "status": "pending",
            ...
        },
        {
            "recipient": "robert.brown@example.com",
            "order": 2,
            "status": "pending",
            ...
        },
        {
            "recipient": "alex.novak@example.com",
            "order": 3,
            "status": "queued",
            ...
        },
        {
            "recipient": "john.smith@example.com",
            "order": null,
            "status": "completed",
            ...
        }
    ],
    ...
}

They are completed, and both managers turned pending at once: a round opens for everybody in it, even when only one of them has to act. HR stays queued for their own round.

Step 4: See the finished transaction

One manager approves — enough for the quorum — and HR countersigns:

GET /transactions/Vt7RfKq2LmXe5ZwGyB4N
Authorization: Bearer YOUR_API_KEY
HTTP/2 200

{
    "id": "Vt7RfKq2LmXe5ZwGyB4N",
    "status": "completed",
    "completedAt": "2026-05-13T15:45:00.000Z",
    "document": {
        "id": "Dq4RfKp2LmXe5ZwGyB4N",
        "mainFileId": "c93e7a1d05b64f28ae3c7d9b06f15a824e0d3b7c9a15f6e820b4d7c3a95e61f0",
        "metadataDefinition": "d_default",
        "signatureProvider": "internal",
        "templateId": null,
        "title": "Employment Agreement"
    },
    "recipients": [
        {
            "recipient": "jane.doe@example.com",
            "purpose": "sign",
            "status": "completed",
            "completedAt": "2026-05-12T14:05:00.000Z",
            ...
        },
        {
            "recipient": "emma.wilson@example.com",
            "purpose": "approve",
            "status": "superseded",
            "completedAt": null,
            ...
        },
        {
            "recipient": "robert.brown@example.com",
            "purpose": "approve",
            "status": "completed",
            "completedAt": "2026-05-13T08:20:00.000Z",
            ...
        },
        {
            "recipient": "alex.novak@example.com",
            "purpose": "sign",
            "status": "completed",
            "completedAt": "2026-05-13T15:45:00.000Z",
            ...
        },
        {
            "recipient": "john.smith@example.com",
            "purpose": "sign",
            "status": "completed",
            "completedAt": "2026-05-11T09:30:00.000Z",
            ...
        }
    ],
    ...
}

The transaction is completed. The manager who acted is completed; the other is superseded — the quorum was met without them, so they were never required to act and have no completedAt. Note document.mainFileId: it is not the file you uploaded, because every signature produces a new one.

Step 5: Read what ended up on the document

GET /documents/Dq4RfKp2LmXe5ZwGyB4N
Authorization: Bearer YOUR_API_KEY
HTTP/2 200

{
    "id": "Dq4RfKp2LmXe5ZwGyB4N",
    "title": "Employment Agreement",
    "signatures": [
        {
            "comment": null,
            "signatureProvider": "internal",
            "signedAt": "2026-05-11T09:30:00.000Z",
            "signedBy": "john.smith@example.com",
            "type": "signature"
        },
        {
            "comment": null,
            "signatureProvider": "internal",
            "signedAt": "2026-05-12T14:05:00.000Z",
            "signedBy": "jane.doe@example.com",
            "type": "signature"
        },
        {
            "comment": null,
            "signatureProvider": "internal",
            "signedAt": "2026-05-13T15:45:00.000Z",
            "signedBy": "alex.novak@example.com",
            "type": "signature"
        }
    ],
    "annotations": [
        {
            "addedAt": "2026-05-12T14:05:00.000Z",
            "addedBy": "jane.doe@example.com",
            "comment": null,
            "text": "Head of Engineering",
            "type": "annotation_generic"
        }
    ],
    ...
}

Three signatures, one for each person who signed — the approval is not among them, because approving records a decision rather than a signature. The job title the new hire typed is stored as an annotation. This is where the values recipients entered live: the transaction tracks the process, the document holds the result.

Step 6: Download the signed agreement

GET /files/c93e7a1d05b64f28ae3c7d9b06f15a824e0d3b7c9a15f6e820b4d7c3a95e61f0/content
Authorization: Bearer YOUR_API_KEY
HTTP/2 200
Content-Type: application/pdf

The response body is the signed PDF itself. Store it wherever your process keeps agreements — it is the artifact the whole flow existed to produce.

Complete example

The whole agreement as a Node.js script: send it signed, hand the new hire their link, wait for the rounds, keep the result. 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 me = await (await fetch(`${BASE_URL}/me`, { headers: AUTH_HEADER })).json();
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
        },
        apply: {
            signatures: [
                {
                    fileId: me.signatures[0].fileId,
                    pages: [
                        1
                    ],
                    position: {
                        x: 0.6,
                        y: 0.66,
                        width: 0.25,
                        height: 0.06
                    }
                }
            ]
        },
        send: {
            sequential: true,
            message: "Please review and sign the renewed employment agreement.",
            recipients: [
                {
                    recipient: "jane.doe@example.com",
                    purpose: "sign",
                    order: 1,
                    verification: {
                        email: {}
                    }
                },
                {
                    recipient: "robert.brown@example.com",
                    purpose: "approve",
                    order: 2,
                    quorum: 1
                },
                {
                    recipient: "emma.wilson@example.com",
                    purpose: "approve",
                    order: 2,
                    quorum: 1
                },
                {
                    recipient: "alex.novak@example.com",
                    purpose: "sign",
                    order: 3
                }
            ],
            signatureFields: [
                {
                    recipients: [
                        "jane.doe@example.com"
                    ],
                    pages: [
                        1
                    ],
                    position: {
                        x: 0.12,
                        y: 0.66,
                        width: 0.25,
                        height: 0.06
                    }
                },
                {
                    recipients: [
                        "alex.novak@example.com"
                    ],
                    pages: [
                        1
                    ],
                    position: {
                        x: 0.6,
                        y: 0.78,
                        width: 0.25,
                        height: 0.06
                    }
                }
            ],
            annotationFields: [
                {
                    recipients: [
                        "jane.doe@example.com"
                    ],
                    pages: [
                        1
                    ],
                    tooltip: "Enter your job title",
                    position: {
                        x: 0.12,
                        y: 0.78,
                        width: 0.3,
                        height: 0.04
                    }
                }
            ]
        }
    })
});
if (!createRes.ok) throw new Error(`Transaction failed: ${createRes.status}`); // error handling kept minimal for brevity

let transaction = await createRes.json();

// the new hire has no account, so onboarding delivers their link instead of e-mail
const newHire = transaction.recipients.find((recipient) => recipient.order === 1);
const linkRes = await fetch(`${BASE_URL}/transactions/${transaction.id}/recipients/${newHire.id}/link`, {
    method: "POST",
    headers: AUTH_HEADER
});
console.log(`Send ${newHire.recipient} to ${(await linkRes.json()).shareUrl}`);

while (transaction.status === "in-progress") {
    await new Promise((resolve) => setTimeout(resolve, 60_000));
    transaction = await (await fetch(`${BASE_URL}/transactions/${transaction.id}`, { headers: AUTH_HEADER })).json();
}
// anything other than completed means somebody refused or a deadline passed
if (transaction.status !== "completed") throw new Error(`The agreement ended as ${transaction.status}`);

const signed = await fetch(`${BASE_URL}/files/${transaction.document.mainFileId}/content`, { headers: AUTH_HEADER });
await fs.writeFile("employment-agreement-signed.pdf", Buffer.from(await signed.arrayBuffer()));

Next steps