Free lesson · System Design Interviews
Step 3: Core API Design
The API design for Pastebin looks deceptively simple - create a paste, view a paste, delete a paste. Three endpoints. But the details inside those endpoints expose design decisions that candidates either nail or fumble. How do you handle a 10 MB upload? What metadata do you return? How does the delete authorization work without user accounts? What happens when someone requests a paste that expired 5 minutes ago?
I've asked candidates to design this API in probably 30 interviews. The ones who impress me aren't the ones who list the endpoints fastest - they're the ones who think about the edge cases within each endpoint and explain why they made each design choice. Let me walk you through it the way I'd want to hear it.
The Four Core APIs
flowchart TD
subgraph APIS["Core APIs"]
CREATE["POST /api/v1/pastes<br/>Create a new paste"]
VIEW["GET /api/v1/pastes/:pasteId<br/>View paste content + metadata"]
RAW["GET /api/v1/pastes/:pasteId/raw<br/>View raw content only"]
DELETE["DELETE /api/v1/pastes/:pasteId<br/>Delete a paste"]
end
CREATE -->|"Returns paste URL + delete token"| CLIENT["Client"]
VIEW -->|"Returns metadata + content"| CLIENT
RAW -->|"Returns plain text content"| CLIENT
DELETE -->|"Requires delete token"| CLIENT
The four core APIs for Pastebin. POST to create a new paste, which returns the paste URL and a delete token. GET with the paste ID to view the paste with metadata and content. GET with the paste ID and a raw path suffix to get just the raw text content without HTML or metadata. DELETE with the paste ID and a delete token in the header to remove a paste. The raw endpoint is important because developers often use Pastebin to share text that gets piped into command-line tools or scripts, and they need the content without any wrapping.
Notice we have four endpoints, not three. The /raw endpoint is important - developers often curl paste URLs from the command line or pipe content into scripts. They need the raw text without HTML wrapping, syntax highlighting, or metadata. If you skip this endpoint, you've missed a core use case for your target audience.
API 1: Create Paste
POST /api/v1/pastes
Request Body:
{
"content": "def hello_world():\n print('Hello, World!')\n\nhello_world()",
"title": "My Python Script", // Optional
"language": "python", // Optional: for syntax highlighting
"expiration": "1_week", // Optional: 10_min, 1_hour, 1_day, 1_week, 1_month, never
"visibility": "unlisted" // Optional: public, unlisted (default: unlisted)
}
Response (201 Created):
{
"paste_id": "x7Kf2mBp",
"url": "https://paste.ly/x7Kf2mBp",
"raw_url": "https://paste.ly/api/v1/pastes/x7Kf2mBp/raw",
"delete_token": "dtk_a8f3e2b1c4d5e6f7a8b9c0d1e2f3a4b5",
"title": "My Python Script",
"language": "python",
"content_size": 64,
"created_at": "2026-06-14T14:30:00Z",
"expires_at": "2026-06-21T14:30:00Z"
}
Error Responses:
- 400 Bad Request: Content is empty, exceeds 10 MB, or contains invalid encoding
- 413 Payload Too Large: Content exceeds the 10 MB limit
- 429 Too Many Requests: Rate limit exceeded
Design Decisions in the Create API
Why is content in the JSON body and not a file upload?
For pastes under 1 MB (98% of pastes), JSON body is simpler for the client - no multipart form encoding, no file handling. The content is just a string field. For pastes between 1-10 MB, JSON still works but is less efficient (Base64 encoding adds 33% overhead if the content contains non-UTF8 characters).
A more sophisticated design would offer both options:
| Method | Use Case | Max Practical Size |
|---|---|---|
JSON body (application/json) |
Small-medium pastes, API clients | ~5 MB (before JSON parsing becomes slow) |
Multipart form upload (multipart/form-data) |
Large pastes, web form submissions | 10 MB |
| Presigned S3 URL | Very large pastes (if we extended beyond 10 MB) | Any size |
For our design, JSON body handles 98% of pastes efficiently. Mention multipart as an alternative for large pastes if the interviewer pushes on the 10 MB upload path.
Interview: What to say: "For pastes under 5 MB - which is 98% of traffic - I'd use a JSON request body. It's simpler for API clients and avoids multipart complexity. For the rare 5-10 MB pastes, I'd also support multipart form upload. If we needed to support uploads larger than 10 MB in the future, I'd use presigned S3 URLs for direct client-to-S3 upload, bypassing our servers entirely."
The delete token - authentication without accounts.
Pastebin supports anonymous paste creation. Without user accounts, how does someone prove they created a paste in order to delete it? The delete token solves this.
When a paste is created, the server generates a cryptographically random token (dtk_a8f3e2b1...) and returns it once. The server stores only the SHA-256 hash of this token in the database. To delete the paste, the client sends the original token, and the server verifies it against the stored hash.
import secrets
import hashlib
def create_paste(content, title, language, expiration):
paste_id = generate_paste_id()
# Generate delete token
delete_token = f"dtk_{secrets.token_hex(16)}"
delete_token_hash = hashlib.sha256(delete_token.encode()).hexdigest()
# Store paste metadata (token hash, never the raw token)
db.execute(
"INSERT INTO paste_metadata (paste_id, title, language, "
"content_size, delete_token_hash, expires_at) "
"VALUES (%s, %s, %s, %s, %s, %s)",
paste_id, title, language, len(content),
delete_token_hash, calculate_expiry(expiration)
)
# Store content in S3
s3.put_object(
Bucket="paste-content",
Key=f"pastes/{paste_id}",
Body=gzip.compress(content.encode()),
ContentEncoding="gzip",
ContentType="text/plain"
)
return {
"paste_id": paste_id,
"delete_token": delete_token, # Returned ONCE, never stored in plaintext
# ... other fields
}
This is the same pattern that GitHub uses for personal access tokens and that Stripe uses for API keys - show the secret once, store only the hash. Mention this pattern in your interview.
Expiration options - why predefined values and not arbitrary timestamps?
| Approach | Pro | Con |
|---|---|---|
| Predefined options (10min, 1h, 1d, 1w, 1m, never) | Simple UI, predictable cleanup scheduling | Less flexible |
| Arbitrary timestamp (ISO 8601) | Maximum flexibility | UI complexity, validation needed, timezone bugs |
| Relative duration (seconds/minutes/hours) | Flexible, no timezone issues | Client must calculate |
Predefined options are the right choice for 95% of use cases. They're simple to implement, easy to display in a dropdown, and map cleanly to cache TTLs. If the interviewer asks about custom durations, mention that you'd accept an ISO 8601 timestamp as an additional option for API clients.
Language detection - optional but valuable.
If the user doesn't specify a language, could we auto-detect it? Yes - libraries like GitHub's Linguist can detect programming languages from content with reasonable accuracy. But auto-detection is imperfect and should never override an explicit user selection.
def resolve_language(user_specified, content):
if user_specified:
return user_specified # User's choice wins
detected = detect_language(content) # GitHub Linguist or similar
if detected and detected.confidence > 0.8:
return detected.language
return "text" # Default to plain text
API 2: View Paste
GET /api/v1/pastes/:pasteId
Example: GET /api/v1/pastes/x7Kf2mBp
Response (200 OK):
{
"paste_id": "x7Kf2mBp",
"title": "My Python Script",
"language": "python",
"content": "def hello_world():\n print('Hello, World!')\n\nhello_world()",
"content_size": 64,
"created_at": "2026-06-14T14:30:00Z",
"expires_at": "2026-06-21T14:30:00Z",
"view_count": 42
}
Error Responses:
- 404 Not Found: Paste doesn't exist
- 410 Gone: Paste existed but has expired or been deleted
Design Decisions in the View API
404 vs 410 for expired pastes.
This is a subtle but important distinction:
| Code | Meaning | When to Use |
|---|---|---|
| 404 Not Found | Never existed | Paste ID doesn't match any record |
| 410 Gone | Existed but was removed | Paste expired or was deleted by creator |
Why does this matter? Search engines treat 404 and 410 differently. A 410 tells Google "stop trying to index this URL - it's permanently gone." A 404 tells Google "this might come back." For expired pastes, 410 is semantically correct and better for SEO hygiene.
From a security perspective, you might prefer 404 for everything - returning 410 confirms that the paste ID was valid, which leaks information to an attacker enumerating paste IDs. In practice, for a public service like Pastebin, this distinction is low-risk. But mention it to show security awareness.
Interview: What to say: "I'd return 410 Gone for expired or deleted pastes and 404 for paste IDs that never existed. 410 tells clients and search engines to stop requesting this URL. If we're concerned about ID enumeration attacks, we could return 404 for everything - but for a public paste service, the security risk is low and the semantic clarity of 410 is worth it."
Separating metadata from content in the response.
For the web UI, returning content inline in the JSON response is fine - the front end renders it with syntax highlighting. But for large pastes (1-10 MB), loading the full content into the API server's memory to embed it in a JSON response is wasteful.
A more scalable approach for large pastes:
Response for large pastes (>100 KB):
{
"paste_id": "x7Kf2mBp",
"title": "Large Log File",
"language": "text",
"content_size": 5242880, // 5 MB
"content_url": "https://cdn.paste.ly/pastes/x7Kf2mBp", // CDN URL for content
"created_at": "2026-06-14T14:30:00Z",
"expires_at": "2026-06-21T14:30:00Z"
}
The client fetches the content directly from the CDN URL, bypassing the API server for the large payload. The API server only serves the small metadata JSON. This is the same pattern AWS uses for pre-signed URLs - separate the metadata API from the content delivery.
flowchart TD
CLIENT["Client"] -->|"1. GET /api/v1/pastes/:id"| API["API Server"]
API -->|"2. Return metadata +<br/>content_url (CDN)"| CLIENT
CLIENT -->|"3. GET content from CDN<br/>(if large paste)"| CDN["CDN / S3"]
CDN -->|"4. Return raw content"| CLIENT
NOTE["For small pastes (<100 KB):<br/>Content embedded in step 2 response.<br/><br/>For large pastes (>100 KB):<br/>Steps 3-4 fetch content from CDN."]
Content delivery strategy for different paste sizes. For small pastes under 100 kilobytes, the content is embedded directly in the API response - one round trip, simple. For large pastes over 100 kilobytes, the API returns metadata with a CDN URL pointing to the content. The client then fetches the content directly from the CDN in a second request. This prevents large pastes from consuming API server memory and bandwidth, and lets the CDN handle the heavy lifting of content delivery.
API 3: View Raw Content
GET /api/v1/pastes/:pasteId/raw
Example: GET /api/v1/pastes/x7Kf2mBp/raw
Response (200 OK):
Content-Type: text/plain; charset=utf-8
Content-Encoding: gzip
def hello_world():
print('Hello, World!')
hello_world()
This endpoint returns the raw text content with no JSON wrapping, no metadata, and no HTML. The response content type is text/plain. This is essential for command-line usage:
# Download a paste directly
curl https://paste.ly/api/v1/pastes/x7Kf2mBp/raw
# Pipe a paste into a local file
curl -s https://paste.ly/api/v1/pastes/x7Kf2mBp/raw > script.py
# Pipe directly into execution (dangerous but common)
curl -s https://paste.ly/api/v1/pastes/x7Kf2mBp/raw | python3
This endpoint is also the one that benefits most from CDN caching because the response is static, cacheable content with no personalisation.
Tip: Always include the raw endpoint in a Pastebin design. It's the endpoint that demonstrates you understand your users. Developers share pastes to be consumed by other tools - not just to be viewed in a browser. A Pastebin without a raw endpoint is like a URL shortener that can't redirect.
API 4: Delete Paste
DELETE /api/v1/pastes/:pasteId
Headers:
X-Delete-Token: dtk_a8f3e2b1c4d5e6f7a8b9c0d1e2f3a4b5
Response (204 No Content):
(empty body)
Error Responses:
- 403 Forbidden: Delete token doesn't match
- 404 Not Found: Paste doesn't exist
- 410 Gone: Paste already expired or deleted
Delete Flow
def delete_paste(paste_id, delete_token):
# 1. Look up the paste metadata
paste = db.query(
"SELECT delete_token_hash FROM paste_metadata WHERE paste_id = %s",
paste_id
)
if not paste:
return 404
# 2. Verify the delete token
provided_hash = hashlib.sha256(delete_token.encode()).hexdigest()
if provided_hash != paste.delete_token_hash:
return 403 # Token doesn't match
# 3. Delete content from S3
s3.delete_object(Bucket="paste-content", Key=f"pastes/{paste_id}")
# 4. Mark metadata as deleted (soft delete)
db.execute(
"UPDATE paste_metadata SET deleted_at = NOW() WHERE paste_id = %s",
paste_id
)
# 5. Invalidate cache
redis.delete(f"paste:meta:{paste_id}")
redis.delete(f"paste:content:{paste_id}")
# 6. Invalidate CDN cache (async)
cdn.create_invalidation(paths=[f"/pastes/{paste_id}*"])
return 204
Soft delete vs hard delete. I'd use soft delete for the metadata (mark as deleted with a timestamp) and hard delete for the content in S3. This way, the metadata record persists for audit purposes and to return proper 410 responses, while the actual content is permanently removed.
CDN cache invalidation. When a paste is deleted, the CDN may still have the content cached at edge nodes. CloudFront invalidation takes 5-10 minutes to propagate globally. During that window, the deleted paste is still accessible from CDN cache. Mention this eventual consistency to show you understand CDN behaviour.
Warning: CDN invalidation is not instant. After deleting a paste, a user might still access it from CDN cache for 5-10 minutes. For a public Pastebin, this is usually acceptable. For a system with stronger deletion requirements (GDPR "right to be forgotten"), you'd need to implement CDN purge verification - confirm the content is removed from all edge nodes before confirming deletion to the user.
Rate Limiting
Rate limiting for Pastebin must account for the content size, not just request count. An attacker creating 100 tiny pastes per minute is less concerning than one creating 100 × 10 MB pastes per minute.
| Endpoint | Rate Limit | Size Limit | Why |
|---|---|---|---|
| POST /api/v1/pastes | 10 pastes/min per IP | 10 MB per paste | Prevent storage abuse |
| GET /api/v1/pastes/:id | 120 requests/min per IP | N/A | Generous for legitimate use |
| GET /api/v1/pastes/:id/raw | 120 requests/min per IP | N/A | Same as view |
| DELETE /api/v1/pastes/:id | 30 requests/min per IP | N/A | Lower limit for destructive operations |
Bandwidth-based rate limiting: In addition to request-count limits, limit the total bytes uploaded per IP per hour. An IP uploading more than 100 MB/hour is likely abusing the service.
Headers on every response:
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 7
X-RateLimit-Reset: 1718380800
X-Content-RateLimit: 100MB/hour
X-Content-RateLimit-Remaining: 87MB
API Versioning and Content Negotiation
The API uses /api/v1/ versioning - same as URL Shortener. But Pastebin adds a content negotiation concern:
# JSON response (default for API clients):
GET /api/v1/pastes/x7Kf2mBp
Accept: application/json
# HTML response (for browser viewing):
GET /api/v1/pastes/x7Kf2mBp
Accept: text/html
# Raw text response (for CLI):
GET /api/v1/pastes/x7Kf2mBp/raw
Accept: text/plain
Alternatively, the web UI and API can be completely separate:
https://paste.ly/x7Kf2mBp- browser-friendly HTML view with syntax highlightinghttps://paste.ly/api/v1/pastes/x7Kf2mBp- JSON API responsehttps://paste.ly/api/v1/pastes/x7Kf2mBp/raw- raw text
This separation keeps the API clean and the web UI flexible.
Abuse Prevention in the API Layer
Pastebin is frequently abused for hosting malicious content - phishing pages, malware URLs, stolen credentials, copyrighted content. The API should include basic abuse prevention:
def create_paste_with_checks(content, **kwargs):
# 1. Size check
if len(content.encode()) > 10 * 1024 * 1024:
return error(413, "Content exceeds 10 MB limit")
# 2. Content scanning (async - don't block creation)
scan_queue.publish({
"paste_id": paste_id,
"content_hash": sha256(content),
"creator_ip": request.remote_addr
})
# 3. Rate limiting (request count + bandwidth)
if rate_limiter.is_exceeded(request.remote_addr):
return error(429, "Rate limit exceeded")
# 4. Spam detection (optional, simple heuristics)
if contains_known_spam_patterns(content):
flag_for_review(paste_id)
# 5. Create the paste normally
return create_paste(content, **kwargs)
Interview: Mention abuse prevention proactively. "Pastebin is frequently used for hosting malicious content - phishing URLs, stolen credentials. I'd add async content scanning after paste creation using a service like AWS Macie or a custom classifier. This doesn't block the creation flow (which would add latency) but flags problematic content for review and potential takedown."
Key Takeaways
-
Four endpoints, not three. The raw content endpoint (
/raw) is essential for developer workflows. Developers share pastes to be consumed by command-line tools, CI/CD pipelines, and scripts - not just browsers. -
Delete tokens enable anonymous deletion. Without user accounts, the delete token (returned once at creation, stored as a hash) lets creators delete their pastes. This is the same pattern as GitHub PATs and Stripe API keys.
-
Separate content delivery from metadata API for large pastes. For pastes over 100 KB, return a CDN URL in the metadata response and let the client fetch content directly from CDN. This prevents large payloads from consuming API server memory.
-
Rate limit by both request count AND bandwidth. A traditional request-count rate limit doesn't protect against storage abuse. An attacker creating 10 × 10 MB pastes per minute consumes 100 MB of storage in a minute. Bandwidth-based rate limiting is essential.
-
410 Gone for expired and deleted pastes, 404 for never-existed. 410 is semantically correct and helps search engines stop indexing expired URLs. Mention the security trade-off of confirming paste existence.
-
Abuse prevention is a first-class API concern. Async content scanning after creation. Spam pattern detection. IP-based rate limiting. These aren't nice-to-haves - Pastebin without abuse prevention becomes a malware hosting platform.
Exercise: Exercise 1: Design the API for a Pastebin that supports paste editing. Add a
PUT /api/v1/pastes/:pasteIdendpoint. What authentication does it require? How do you handle concurrent edits from multiple users who have the edit token? Does editing change the paste URL or create a new version? What cache invalidation is needed?Exercise 2: Design an API for paste embedding - allowing other websites to embed a paste's content in an iframe with syntax highlighting. What endpoint would you create? What security headers (CSP, X-Frame-Options) are needed? How do you prevent embedding from becoming a vector for XSS attacks?
Exercise 3: The product team wants to add a "paste history" feature - when a user visits the site, they see their recent pastes (even if they don't have an account). Design the API using browser cookies or localStorage. How do you handle paste IDs securely in cookies without enabling someone to view another user's pastes?