Logo
Apdf tutorials July 2026 5 min read

How to Send Personalized Tracked Links to Hundreds of Recipients

You're about to send the same PDF to a whole list — a pricing guide to prospects, an update to investors, a catalog to customers. Paste one shared URL into the mail merge and you'll learn exactly one thing: somebody opened it. Forty sessions, zero names.

The fix is one personal link per contact, minted by a script in the time it takes to pour a coffee. Every open, page and dwell time comes back attributed — Priya read the rates page twice beats somebody viewed the document.

What you'll build
A contact CSV in, a mail-merge-ready CSV out — thirty personal tracking links minted by a rate-limit-aware Node.js script: 30/30 links written to links.csv — every future session has a name on it
Free plan: 25 links per document · Pro: 250 · Business: 1,000
1

Upload the document — private

One upload serves the whole list. Keep it private so the personal links are the only way in:

curl -X POST https://apdf.io/api/docs \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Accept: application/json" \
  --data-urlencode "file=https://your-site.example/pricing-guide.pdf" \
  --data-urlencode "name=2026 Services & Pricing Guide" \
  -d "is_public=0"
{
    "data": {
        "doc_id": "688b0-07ba8-42bc4",
        "viewer_url": "https://docs.apdf.io/studio/688b0-07ba8-42bc4",
        "file_url": "https://apdf-files.s3.eu-central-1.amazonaws.com/docs/202607/f7d67b4f6a61c8d2571a7.pdf",
        "name": "2026 Services & Pricing Guide",
        "file_size": 2092,
        "file_pages": 4,
        "is_public": false,
        "created_at": "2026-07-23T07:54:58.000000Z",
        "archived_at": null
    }
}
Tip: Your workspace needs a handle first (set once in the dashboard) — it becomes part of every viewer URL. And mind the shell: an & in the document name needs --data-urlencode, or curl will split it into a second parameter.
2

Start from the list you already have

Any CRM or spreadsheet exports this — a name and an email per row is all the script needs:

name,email
Priya Raman,priya@meridianlabs.example
Jonas Weber,jonas@haugen.example
Sofia Castellanos,sofia@castellanos.example
Marcus Chen,marcus@brightpath.example
...
3

Mint a link per contact — politely

The API rate-limits bursts (a parallel blast gets 429 Too Many Attempts after the first handful), so the script paces itself to about one request per second and, if it's ever throttled anyway, waits out the Retry-After header instead of dropping the contact:

import fs from 'node:fs';

const BASE = 'https://apdf.io';
const DOC_ID = process.env.DOC_ID;
const TOKEN = process.env.API_TOKEN;
const PACE_MS = 1100; // stay under the per-minute API rate limit

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

const contacts = fs.readFileSync('contacts.csv', 'utf8').trim().split('\n').slice(1)
    .map(line => {
        const [name, email] = line.split(',');
        return { name: name.trim(), email: email.trim() };
    });

async function mintLink(contact) {
    while (true) {
        const response = await fetch(`${BASE}/api/docs/${DOC_ID}/links`, {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${TOKEN}`,
                'Accept': 'application/json',
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(contact),
        });

        if (response.status === 429) {
            const wait = Number(response.headers.get('retry-after') ?? 10);
            console.log(`  rate limited — waiting ${wait}s`);
            await sleep(wait * 1000);
            continue;
        }

        if (!response.ok) {
            throw new Error(`${contact.email}: ${await response.text()}`);
        }

        return (await response.json()).data;
    }
}

const results = [];

for (const contact of contacts) {
    try {
        const link = await mintLink(contact);
        results.push({ ...contact, url: link.url });
        console.log(`✓ ${contact.name} → ${link.url}`);
    } catch (error) {
        console.error(`✗ ${error.message}`);
    }
    await sleep(PACE_MS);
}

const csv = ['name,email,url', ...results.map(r => `${r.name},${r.email},${r.url}`)].join('\n');
fs.writeFileSync('links.csv', csv);
console.log(`\n${results.length}/${contacts.length} links written to links.csv`);

Run it with DOC_ID=688b0-07ba8-42bc4 API_TOKEN=… node mint-links.mjs and watch the roster build:

✓ Priya Raman → https://docs.apdf.io/studio/688b0-07ba8-42bc4?v=42YAsbYU
✓ Jonas Weber → https://docs.apdf.io/studio/688b0-07ba8-42bc4?v=N5vMicMn
✓ Sofia Castellanos → https://docs.apdf.io/studio/688b0-07ba8-42bc4?v=xiN8UuB5
...
✓ Carmen Ruiz → https://docs.apdf.io/studio/688b0-07ba8-42bc4?v=8qvsCc1i

30/30 links written to links.csv
Plan note: Links are quota'd per document: 25 on the free plan, 250 on Pro, 1,000 on Business. Past the cap the API answers with an explicit refusal instead of a silent drop — the script prints it and moves on: {"error":"quota_exceeded","quota":"recipient_links_per_document","limit":25,"used":30,"upgrade_url":"https://apdf.io/pricing"}
4

Merge, send — and know who's who

links.csv is your original list plus one new column, which makes it a drop-in mail-merge source — map url to a merge field in Gmail, Outlook, Mailchimp or whatever sends your campaign:

name,email,url
Priya Raman,priya@meridianlabs.example,https://docs.apdf.io/studio/688b0-07ba8-42bc4?v=42YAsbYU
Jonas Weber,jonas@haugen.example,https://docs.apdf.io/studio/688b0-07ba8-42bc4?v=N5vMicMn
Sofia Castellanos,sofia@castellanos.example,https://docs.apdf.io/studio/688b0-07ba8-42bc4?v=xiN8UuB5
...

From the first click, every session lands in your analytics with the contact's name and email attached — who opened, what they read, where they stopped. GET /api/docs/{docId}/links?per_page=100 returns the full roster with each link's is_active state and last_viewed_at, so the same list that sent the campaign also audits it.

Where to go from here

The links are out — now put the names they collect to work.

Ready to see who reads?