ekofyi
API Reverse Engineering8 min read

5 things I check before trusting an API

Before I integrate with any API — official or reverse-engineered — I run through this checklist to avoid surprises later.

Whether I'm building an integration for a client or automating something for myself, I never trust an API at face value. Docs lie. Behavior changes. Undocumented rate limits kill your bot at 3am.

These 5 checks take about 10 minutes. They've saved me weeks of debugging production failures.

1. Does it actually rate limit?

The docs say "100 requests per minute." But is that enforced? I test it directly:

bash
$ time for i in $(seq 1 150); do
  curl -so /dev/null -w "%{http_code}\n" \
    https://api.target.com/v1/users \
    -H "Authorization: Bearer $TOKEN"
done | sort | uniq -c

# Possible outcomes:
  150 200 No rate limiting at all
  100 200, 50 429 Rate limiting works as documented
  148 200, 2 500 Rate limiting exists but is buggy

The third outcome is the most dangerous for automation. It means the API has rate limiting but it's implemented poorly — sometimes it triggers, sometimes it doesn't, and when it does, it might crash instead of returning a clean 429.

💡 What I look for in the response

http
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1704067260
Retry-After: 30

If these headers exist, I can build adaptive throttling. If they don't, I'm flying blind and need to implement my own backoff logic.

2. What happens when the token expires?

I intentionally let my token expire and hit the API. The response tells me everything about error handling quality:

http
# Good: Clean 401 with machine-readable error
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "error": "token_expired",
  "message": "Access token has expired",
  "expired_at": "2024-01-15T10:30:00Z"
}

# Bad: Generic 500 with stack trace
HTTP/1.1 500 Internal Server Error
Content-Type: text/html

<h1>Error</h1>
<pre>JsonWebTokenError: jwt expired
    at /app/node_modules/jsonwebtoken/verify.js:152:21
    at /app/middleware/auth.js:34:5</pre>

# Worst: Silent 200 with empty/wrong data
HTTP/1.1 200 OK
{"users": []}

⚠️ Why this matters for automation

If expired tokens cause 500s, the API is fragile. Your automation needs aggressive retry logic and token refresh before every batch. If it returns empty data silently, your bot might run for days thinking everything is fine while actually getting nothing.

3. Are IDs sequential or random?

I create two resources and compare their IDs:

bash
# Sequential (bad for security, good for enumeration)
POST /api/documents {"id": 4521}
POST /api/documents {"id": 4522}
# I now know: there are ~4522 documents total.
# I can enumerate: /api/documents/1, /api/documents/2, ...

# UUID (better)
POST /api/documents {"id": "7f3a2b1c-9d4e-4f5a-8b6c-1d2e3f4a5b6c"}
POST /api/documents {"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}
# Can't enumerate. Can't guess total count.

Sequential IDs tell me three things:

  • Total record count is guessable (competitive intelligence)
  • Enumeration attacks are trivial (IDOR testing)
  • The API owner can detect bulk sequential access (my bot might get flagged)

If I'm building automation against sequential IDs, I randomize access order and add jitter to avoid detection patterns.

4. Does it version properly?

http
# Versioned (safe to build against)
GET /api/v2/users HTTP/1.1
# → v1 still works, v2 has new fields, v3 coming next quarter

# Unversioned (dangerous)
GET /api/users HTTP/1.1
# → Response format can change any time without warning

I've had automations die overnight because an unversioned API changed its response format. The field was user_name on Monday and username on Tuesday. No deprecation notice, no changelog, no version bump.

💡 My defensive pattern

python
# I always validate response shape before processing
def validate_user(data: dict) -> bool:
    required = ['id', 'email', 'created_at']
    return all(k in data for k in required)

# If validation fails → alert, don't silently break
users = api.get('/users')
for user in users:
    if not validate_user(user):
        alert(f"Schema changed! Missing keys in: {user.keys()}")
        break

5. What does the error format look like?

I intentionally send broken requests to map the error surface:

bash
# Test 1: Missing required field
POST /api/users -d '{"name": "test"}'
# Expected: 422 with field-level errors

# Test 2: Wrong type
POST /api/users -d '{"email": 12345}'
# Expected: 422 with type mismatch error

# Test 3: Invalid JSON
POST /api/users -d 'not json at all'
# Expected: 400 with parse error

# Test 4: Oversized payload
POST /api/users -d '{"bio": "<100KB string>"}'
# Expected: 413 with size limit info
Error qualityWhat it meansTrust level
Structured JSON errorsTeam cares about DXHigh
Generic "Bad Request"Minimal effortMedium
HTML error pagesFramework defaults, no customizationLow
500 with stack traceDebug mode in productionVery low

The meta-lesson

text
┌─ API TRUST ASSESSMENT ───────────────────────────┐
│                                                  │
│  Rate limiting    → How much can I push?         │
│  Error handling   → How fragile is it?           │
│  ID format        → How detectable am I?         │
│  Versioning       → How stable is the contract?  │
│  Error format     → How much does the team care? │
│                                                  │
│  Time to assess: ~10 minutes                     │
│  Time saved: weeks of 3am debugging              │
│                                                  │
└──────────────────────────────────────────────────┘

An API that fails these basic checks will cause problems later — it's just a matter of when. Better to know upfront and build defensively than to discover it when your automation breaks in production.

Related posts

Written by Eko

If you found this useful, follow @ekofyi on X for more notes like this — or get in touch if you have a problem to solve.