AlgoInsight

Free lesson · System Design Interviews

Step 3: Core API Design

Hard12 min readsystem-designinterview-prep

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:

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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/:pasteId endpoint. 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?

Back to System Design Interviews