Build a Bulk Link Generator
Learn how to build a production-ready tool that generates hundreds of short links from CSV files, with proper error handling, rate limiting, and retry logic.
What You'll Build
By the end of this tutorial, you'll have a production-ready command-line tool that can:
- Read URLs from CSV files - Process hundreds or thousands of URLs
- Generate short links automatically - Batch create links with custom slugs, tags, and metadata
- Handle rate limits gracefully - Respect SnipLink's 100 requests per 15 minutes limit
- Retry failed requests - Exponential backoff for transient errors
- Log progress and errors - Track which links succeeded or failed
- Export results to CSV - Get a complete report of all generated links
Prerequisites
- A SnipLink account with Pro plan or higher (sign up here)
- An API key with
links:writescope (learn how) - Your workspace ID (found in dashboard URL)
- Basic programming knowledge in Node.js, Python, or TypeScript (choose your preferred language)
Understanding Rate Limits
Before we start coding, let's understand SnipLink's rate limits:
- 100 requests per 15 minutes for standard API endpoints
- 20 requests per hour for heavy operations (bulk imports, analytics exports)
This means if you have 500 URLs to process, you'll need about 75 minutes (500 รท 100 ร 15 minutes). Our code will handle this automatically with a rate limiting.
Step 1: Set Up Your Environment
Node.js Setup
Create a new project and install dependencies:
mkdir sniplink-bulk-generator
cd sniplink-bulk-generator
npm init -y
npm install node-fetch csv-parse csv-stringify dotenvCreate a .env file for your credentials:
SNIPLINK_API_KEY=snip_live_your_api_key_here
SNIPLINK_WORKSPACE_ID=ws_xxxxxxxxxxxxx.env file to version control. Add it to .gitignore immediately.Python Setup
mkdir sniplink-bulk-generator
cd sniplink-bulk-generator
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install requests python-dotenvCreate a .env file:
SNIPLINK_API_KEY=snip_live_your_api_key_here
SNIPLINK_WORKSPACE_ID=ws_xxxxxxxxxxxxxTypeScript Setup
mkdir sniplink-bulk-generator
cd sniplink-bulk-generator
npm init -y
npm install tsx
npm install -D typescript @types/node
npm install csv-parse csv-stringify dotenv
npx tsc --initCreate a .env file (same as Node.js example above).
Step 2: Create Sample CSV Input
Create a file called links.csv with your URLs:
url,slug,title,tags
https://example.com/product/awesome-widget,awesome-widget,Awesome Widget Product Page,"product,widgets"
https://example.com/blog/how-to-start,blog-start,How to Get Started,"blog,tutorial"
https://example.com/pricing,pricing-2026,Pricing 2026,"pricing,sales"
https://example.com/contact,contact,Contact Us,"support,contact"The CSV format supports these columns (all optional except url):
url- Required: The destination URL to shortenslug- Custom short link slug (leave empty for auto-generated)title- Descriptive title for the linktags- Comma-separated tags for organizationexpiresAt- ISO 8601 date when link expires (e.g.,2026-12-31T23:59:59Z)password- Password protection (Pro plan required)
Step 3: Build the Rate Limiter
The most critical component is the rate limiter. We'll use a rate limiting:
Node.js Rate Limiter
// rateLimiter.js
class RateLimiter {
constructor(maxRequests = 100, windowMs = 15 * 60 * 1000) {
this.maxRequests = maxRequests; // 100 requests
this.windowMs = windowMs; // 15 minutes in milliseconds
this.tokens = maxRequests; // Start with full bucket
this.lastRefill = Date.now();
}
async acquire() {
this.refill();
if (this.tokens > 0) {
this.tokens--;
return;
}
// No tokens available - calculate wait time
const timeUntilRefill = this.windowMs - (Date.now() - this.lastRefill);
console.log(`Rate limit reached. Waiting ${Math.ceil(timeUntilRefill / 1000)}s...`);
await this.sleep(timeUntilRefill);
this.refill();
this.tokens--;
}
refill() {
const now = Date.now();
const timePassed = now - this.lastRefill;
if (timePassed >= this.windowMs) {
this.tokens = this.maxRequests;
this.lastRefill = now;
}
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = { RateLimiter };Python Rate Limiter
# rate_limiter.py
import time
from datetime import datetime, timedelta
class RateLimiter:
def __init__(self, max_requests=100, window_seconds=900):
self.max_requests = max_requests # 100 requests
self.window_seconds = window_seconds # 15 minutes = 900 seconds
self.tokens = max_requests
self.last_refill = time.time()
def acquire(self):
self._refill()
if self.tokens > 0:
self.tokens -= 1
return
# No tokens available - calculate wait time
time_until_refill = self.window_seconds - (time.time() - self.last_refill)
print(f"Rate limit reached. Waiting {int(time_until_refill)}s...")
time.sleep(time_until_refill)
self._refill()
self.tokens -= 1
def _refill(self):
now = time.time()
time_passed = now - self.last_refill
if time_passed >= self.window_seconds:
self.tokens = self.max_requests
self.last_refill = nowTypeScript Rate Limiter
// rateLimiter.ts
export class RateLimiter {
private tokens: number;
private lastRefill: number;
constructor(
private maxRequests: number = 100,
private windowMs: number = 15 * 60 * 1000
) {
this.tokens = maxRequests;
this.lastRefill = Date.now();
}
async acquire(): Promise<void> {
this.refill();
if (this.tokens > 0) {
this.tokens--;
return;
}
// No tokens available - calculate wait time
const timeUntilRefill = this.windowMs - (Date.now() - this.lastRefill);
console.log(`Rate limit reached. Waiting ${Math.ceil(timeUntilRefill / 1000)}s...`);
await this.sleep(timeUntilRefill);
this.refill();
this.tokens--;
}
private refill(): void {
const now = Date.now();
const timePassed = now - this.lastRefill;
if (timePassed >= this.windowMs) {
this.tokens = this.maxRequests;
this.lastRefill = now;
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}Step 4: Build the Link Creator
Now let's create the core function that calls the SnipLink API:
Node.js Link Creator
// linkCreator.js
require(039;dotenv039;).config();
const fetch = require(039;node-fetch039;);
const API_BASE = 039;https://api.sniplink.co/v1';
const API_KEY = process.env.SNIPLINK_API_KEY;
const WORKSPACE_ID = process.env.SNIPLINK_WORKSPACE_ID;
async function createLink(linkData, rateLimiter, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
// Wait for rate limiter to allow the request
await rateLimiter.acquire();
const response = await fetch(
`${API_BASE}/workspaces/${WORKSPACE_ID}/links`,
{
method: 039;POST039;,
headers: {
039;X-API-Key039;: API_KEY,
039;Content-Type039;: 039;application/json039;,
},
body: JSON.stringify(linkData),
}
);
const data = await response.json();
if (response.ok) {
return {
success: true,
link: data.link,
};
}
// Handle specific error cases
if (response.status === 409) {
// Slug already exists
return {
success: false,
error: `Slug '${linkData.slug}' already exists`,
code: 039;SLUG_EXISTS039;,
};
}
if (response.status === 403) {
// Feature not available on current plan
return {
success: false,
error: data.message || 039;Feature not available on your plan039;,
code: 039;PLAN_LIMIT039;,
};
}
if (response.status === 429) {
// Rate limit hit (shouldn't happen with our rate limiter, but handle it)
const waitTime = 60 * 1000; // Wait 1 minute
console.log(`API rate limit hit. Waiting ${waitTime / 1000}s...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
continue; // Retry
}
if (response.status >= 500) {
// Server error - retry with exponential backoff
if (attempt < retries) {
const backoff = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
console.log(`Server error. Retrying in ${backoff / 1000}s...`);
await new Promise(resolve => setTimeout(resolve, backoff));
continue;
}
}
// Other client errors (400, etc.)
return {
success: false,
error: data.message || 039;Unknown error039;,
code: 039;API_ERROR039;,
};
} catch (error) {
if (attempt < retries) {
const backoff = Math.pow(2, attempt) * 1000;
console.log(`Network error. Retrying in ${backoff / 1000}s...`);
await new Promise(resolve => setTimeout(resolve, backoff));
continue;
}
return {
success: false,
error: error.message,
code: 039;NETWORK_ERROR039;,
};
}
}
return {
success: false,
error: 039;Max retries exceeded039;,
code: 039;MAX_RETRIES039;,
};
}
module.exports = { createLink };Python Link Creator
# link_creator.py
import os
import time
import requests
from dotenv import load_dotenv
load_dotenv()
API_BASE = 'https://api.sniplink.co/v1'
API_KEY = os.getenv('SNIPLINK_API_KEY')
WORKSPACE_ID = os.getenv('SNIPLINK_WORKSPACE_ID')
def create_link(link_data, rate_limiter, retries=3):
for attempt in range(1, retries + 1):
try:
# Wait for rate limiter to allow the request
rate_limiter.acquire()
response = requests.post(
f'{API_BASE}/workspaces/{WORKSPACE_ID}/links',
headers={
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
json=link_data,
)
if response.ok:
data = response.json()
return {
'success': True,
'link': data['link'],
}
data = response.json() if response.headers.get('content-type') == 'application/json' else {}
# Handle specific error cases
if response.status_code == 409:
return {
'success': False,
'error': f"Slug '{link_data.get('slug')}' already exists",
'code': 'SLUG_EXISTS',
}
if response.status_code == 403:
return {
'success': False,
'error': data.get('message', 'Feature not available on your plan'),
'code': 'PLAN_LIMIT',
}
if response.status_code == 429:
wait_time = 60 # Wait 1 minute
print(f'API rate limit hit. Waiting {wait_time}s...')
time.sleep(wait_time)
continue # Retry
if response.status_code >= 500:
if attempt < retries:
backoff = 2 ** attempt # 2s, 4s, 8s
print(f'Server error. Retrying in {backoff}s...')
time.sleep(backoff)
continue
# Other client errors
return {
'success': False,
'error': data.get('message', 'Unknown error'),
'code': 'API_ERROR',
}
except requests.exceptions.RequestException as error:
if attempt < retries:
backoff = 2 ** attempt
print(f'Network error. Retrying in {backoff}s...')
time.sleep(backoff)
continue
return {
'success': False,
'error': str(error),
'code': 'NETWORK_ERROR',
}
return {
'success': False,
'error': 'Max retries exceeded',
'code': 'MAX_RETRIES',
}TypeScript Link Creator
// linkCreator.ts
import 039;dotenv/config039;;
import { RateLimiter } from 039;./rateLimiter039;;
const API_BASE = 039;https://api.sniplink.co/v1';
const API_KEY = process.env.SNIPLINK_API_KEY!;
const WORKSPACE_ID = process.env.SNIPLINK_WORKSPACE_ID!;
interface LinkData {
url: string;
slug?: string;
title?: string;
tags?: string[];
expiresAt?: string;
password?: string;
}
interface CreateLinkResult {
success: boolean;
link?: any;
error?: string;
code?: string;
}
export async function createLink(
linkData: LinkData,
rateLimiter: RateLimiter,
retries = 3
): Promise<CreateLinkResult> {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
await rateLimiter.acquire();
const response = await fetch(
`${API_BASE}/workspaces/${WORKSPACE_ID}/links`,
{
method: 039;POST039;,
headers: {
039;X-API-Key039;: API_KEY,
039;Content-Type039;: 039;application/json039;,
},
body: JSON.stringify(linkData),
}
);
const data = await response.json();
if (response.ok) {
return {
success: true,
link: data.link,
};
}
// Handle specific error cases
if (response.status === 409) {
return {
success: false,
error: `Slug '${linkData.slug}' already exists`,
code: 039;SLUG_EXISTS039;,
};
}
if (response.status === 403) {
return {
success: false,
error: data.message || 039;Feature not available on your plan039;,
code: 039;PLAN_LIMIT039;,
};
}
if (response.status === 429) {
const waitTime = 60 * 1000;
console.log(`API rate limit hit. Waiting ${waitTime / 1000}s...`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
continue;
}
if (response.status >= 500) {
if (attempt < retries) {
const backoff = Math.pow(2, attempt) * 1000;
console.log(`Server error. Retrying in ${backoff / 1000}s...`);
await new Promise((resolve) => setTimeout(resolve, backoff));
continue;
}
}
return {
success: false,
error: data.message || 039;Unknown error039;,
code: 039;API_ERROR039;,
};
} catch (error) {
if (attempt < retries) {
const backoff = Math.pow(2, attempt) * 1000;
console.log(`Network error. Retrying in ${backoff / 1000}s...`);
await new Promise((resolve) => setTimeout(resolve, backoff));
continue;
}
return {
success: false,
error: error instanceof Error ? error.message : 039;Unknown error039;,
code: 039;NETWORK_ERROR039;,
};
}
}
return {
success: false,
error: 039;Max retries exceeded039;,
code: 039;MAX_RETRIES039;,
};
}Step 5: Build the Main Script
Finally, let's tie everything together with CSV processing and progress tracking:
Node.js Main Script
// index.js
const fs = require(039;fs039;);
const { parse } = require(039;csv-parse/sync039;);
const { stringify } = require(039;csv-stringify/sync039;);
const { RateLimiter } = require(039;./rateLimiter039;);
const { createLink } = require(039;./linkCreator039;);
async function main() {
console.log(039;๐ SnipLink Bulk Link Generator\n039;);
// Read input CSV
const inputFile = process.argv[2] || 039;links.csv039;;
const csvContent = fs.readFileSync(inputFile, 039;utf-8039;);
const records = parse(csvContent, { columns: true, skip_empty_lines: true });
console.log(`๐ Found ${records.length} URLs to process\n`);
// Initialize rate limiter
const rateLimiter = new RateLimiter();
// Process links
const results = [];
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < records.length; i++) {
const record = records[i];
const linkData = {
url: record.url,
...(record.slug && { slug: record.slug }),
...(record.title && { title: record.title }),
...(record.tags && { tags: record.tags.split(039;,039;).map(t => t.trim()) }),
...(record.expiresAt && { expiresAt: record.expiresAt }),
...(record.password && { password: record.password }),
};
console.log(`[${i + 1}/${records.length}] Processing: ${record.url}`);
const result = await createLink(linkData, rateLimiter);
if (result.success) {
console.log(` โ
Created: ${result.link.shortUrl}`);
successCount++;
results.push({
...record,
status: 039;success039;,
shortUrl: result.link.shortUrl,
linkId: result.link.id,
});
} else {
console.log(` โ Failed: ${result.error}`);
errorCount++;
results.push({
...record,
status: 039;error039;,
error: result.error,
errorCode: result.code,
});
}
}
// Write results to CSV
const outputFile = 039;results.csv039;;
const csvOutput = stringify(results, { header: true });
fs.writeFileSync(outputFile, csvOutput);
console.log(`\nโจ Done!\n`);
console.log(` โ
Successful: ${successCount}`);
console.log(` โ Failed: ${errorCount}`);
console.log(` ๐ Results saved to: ${outputFile}\n`);
}
main().catch(console.error);Python Main Script
# main.py
import csv
import sys
from rate_limiter import RateLimiter
from link_creator import create_link
def main():
print('๐ SnipLink Bulk Link Generator\n')
# Read input CSV
input_file = sys.argv[1] if len(sys.argv) > 1 else 'links.csv'
with open(input_file, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
records = list(reader)
print(f'๐ Found {len(records)} URLs to process\n')
# Initialize rate limiter
rate_limiter = RateLimiter()
# Process links
results = []
success_count = 0
error_count = 0
for i, record in enumerate(records):
link_data = {'url': record['url']}
if record.get('slug'):
link_data['slug'] = record['slug']
if record.get('title'):
link_data['title'] = record['title']
if record.get('tags'):
link_data['tags'] = [t.strip() for t in record['tags'].split(',')]
if record.get('expiresAt'):
link_data['expiresAt'] = record['expiresAt']
if record.get('password'):
link_data['password'] = record['password']
print(f'[{i + 1}/{len(records)}] Processing: {record["url"]}')
result = create_link(link_data, rate_limiter)
if result['success']:
print(f' โ
Created: {result["link"]["shortUrl"]}')
success_count += 1
results.append({
**record,
'status': 'success',
'shortUrl': result['link']['shortUrl'],
'linkId': result['link']['id'],
})
else:
print(f' โ Failed: {result["error"]}')
error_count += 1
results.append({
**record,
'status': 'error',
'error': result['error'],
'errorCode': result.get('code'),
})
# Write results to CSV
output_file = 'results.csv'
if results:
with open(output_file, 'w', encoding='utf-8', newline='') as f:
fieldnames = list(results[0].keys())
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(results)
print('\nโจ Done!\n')
print(f' โ
Successful: {success_count}')
print(f' โ Failed: {error_count}')
print(f' ๐ Results saved to: {output_file}\n')
if __name__ == '__main__':
main()TypeScript Main Script
// index.ts
import fs from 039;fs039;;
import { parse } from 039;csv-parse/sync039;;
import { stringify } from 039;csv-stringify/sync039;;
import { RateLimiter } from 039;./rateLimiter039;;
import { createLink } from 039;./linkCreator039;;
async function main() {
console.log(039;๐ SnipLink Bulk Link Generator\n039;);
// Read input CSV
const inputFile = process.argv[2] || 039;links.csv039;;
const csvContent = fs.readFileSync(inputFile, 039;utf-8039;);
const records = parse(csvContent, { columns: true, skip_empty_lines: true });
console.log(`๐ Found ${records.length} URLs to process\n`);
// Initialize rate limiter
const rateLimiter = new RateLimiter();
// Process links
const results: any[] = [];
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < records.length; i++) {
const record = records[i];
const linkData: any = {
url: record.url,
...(record.slug && { slug: record.slug }),
...(record.title && { title: record.title }),
...(record.tags && { tags: record.tags.split(039;,039;).map((t: string) => t.trim()) }),
...(record.expiresAt && { expiresAt: record.expiresAt }),
...(record.password && { password: record.password }),
};
console.log(`[${i + 1}/${records.length}] Processing: ${record.url}`);
const result = await createLink(linkData, rateLimiter);
if (result.success) {
console.log(` โ
Created: ${result.link.shortUrl}`);
successCount++;
results.push({
...record,
status: 039;success039;,
shortUrl: result.link.shortUrl,
linkId: result.link.id,
});
} else {
console.log(` โ Failed: ${result.error}`);
errorCount++;
results.push({
...record,
status: 039;error039;,
error: result.error,
errorCode: result.code,
});
}
}
// Write results to CSV
const outputFile = 039;results.csv039;;
const csvOutput = stringify(results, { header: true });
fs.writeFileSync(outputFile, csvOutput);
console.log(`\nโจ Done!\n`);
console.log(` โ
Successful: ${successCount}`);
console.log(` โ Failed: ${errorCount}`);
console.log(` ๐ Results saved to: ${outputFile}\n`);
}
main().catch(console.error);Step 6: Run Your Generator
Node.js
node index.js links.csvPython
python main.py links.csvTypeScript
npx tsx index.ts links.csvYou'll see progress output like this:
๐ SnipLink Bulk Link Generator
๐ Found 4 URLs to process
[1/4] Processing: https://example.com/product/awesome-widget
โ
Created: https://r.sniplink.co/awesome-widget
[2/4] Processing: https://example.com/blog/how-to-start
โ
Created: https://r.sniplink.co/blog-start
[3/4] Processing: https://example.com/pricing
โ
Created: https://r.sniplink.co/pricing-2026
[4/4] Processing: https://example.com/contact
โ
Created: https://r.sniplink.co/contact
โจ Done!
โ
Successful: 4
โ Failed: 0
๐ Results saved to: results.csvUnderstanding the Results
Your results.csv file will contain all the original columns plus:
status- Either "success" or "error"shortUrl- The generated short link (for successful creations)linkId- The SnipLink ID (for successful creations)error- Error message (for failed creations)errorCode- Error code like SLUG_EXISTS, PLAN_LIMIT, etc.
Advanced: Handling Common Errors
Duplicate Slugs
If you try to create a link with a slug that already exists, you'll get a SLUG_EXISTS error. You can modify the script to auto-generate alternative slugs:
if (result.code === 039;SLUG_EXISTS039;) {
// Try again with a timestamp suffix
linkData.slug = `${linkData.slug}-${Date.now()}`;
result = await createLink(linkData, rateLimiter);
}Plan Limits
If you hit a plan limit (e.g., trying to use password protection on Free tier), you'll get a PLAN_LIMIT error. The script will log this and continue with other links.
Network Errors
The retry logic with exponential backoff handles transient network issues automatically. If a request fails 3 times, it's logged as NETWORK_ERROR.
Production Tips
Batch Size Optimization
For very large CSV files (10,000+ URLs), consider processing in batches:
// Process in chunks of 100
const BATCH_SIZE = 100;
for (let i = 0; i < records.length; i += BATCH_SIZE) {
const batch = records.slice(i, i + BATCH_SIZE);
await processBatch(batch);
// Save intermediate results
fs.writeFileSync(039;results-partial.csv039;, stringify(results, { header: true }));
}Progress Persistence
Save progress to resume interrupted jobs:
// Before processing, check if results file exists
if (fs.existsSync(039;results.csv039;)) {
const existing = parse(fs.readFileSync(039;results.csv039;, 039;utf-8039;), { columns: true });
const processedUrls = new Set(existing.map(r => r.url));
records = records.filter(r => !processedUrls.has(r.url));
results.push(...existing);
}Enhanced Logging
Add detailed logging for debugging:
const winston = require(039;winston039;);
const logger = winston.createLogger({
level: 039;info039;,
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 039;error.log039;, level: 039;error039; }),
new winston.transports.File({ filename: 039;combined.log039; }),
],
});
// Log each API call
logger.info(039;Creating link039;, { url: linkData.url, slug: linkData.slug });Monitoring Rate Limit Usage
Track your rate limit consumption:
console.log(`Rate limit: ${rateLimiter.tokens}/${rateLimiter.maxRequests} requests available`);Next Steps
Now that you have a working bulk link generator, you can:
- Add scheduling: Use cron jobs to generate links on a schedule
- Integrate with databases: Read URLs from PostgreSQL, MySQL, or MongoDB
- Build a web UI: Create a simple form to upload CSVs
- Add analytics: Fetch click analytics after link creation
- Integrate with webhooks: Get notified when links are clicked
Need Help?
Having trouble with this tutorial? We're here to help.