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.
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
}
}
& in the document name needs --data-urlencode, or
curl will split it into a second parameter.
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
...
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
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.