Working with APIs — REST, Auth, Pagination, Rate Limits
How APIs work, every auth pattern, all pagination styles, rate limits, and webhooks vs polling — built as one real payment-ingestion pipeline, not a wall of unrelated snippets.
Why Every Data Engineer Must Be Fluent with APIs
A data engineer who cannot work confidently with APIs is blocked from half the data sources they will encounter. Payment processors, CRM systems, marketing platforms, logistics partners — none of them hand you a database connection string. They hand you an API key and a documentation URL.
This module is built around one real, ongoing example: FreshCart needs a pipeline that pulls transaction data from its payment gateway into the warehouse. Every technique below — auth, pagination, rate limits, webhooks — is a piece of that one pipeline, built up incrementally, not a disconnected reference for eight unrelated topics. Near the end, the Real World section applies the exact same process to onboarding a second, completely different vendor, so you see the pattern generalise.
HTTP and REST — What Actually Happens When You Call an API
Every API call is an HTTP request. Understanding its anatomy — method, headers, status code, body — lets you diagnose problems instantly and write code that handles every response correctly, instead of only the happy path.
What your code actually sends
GET /v1/payments?from=1710633000&to=1710719400&count=100 HTTP/1.1
Host: api.payment-gateway.example.com
Authorization: Bearer sk_live_xxxxxxxxxxxx
Accept: application/json
User-Agent: FreshCart-Pipeline/1.0Five pieces make up every request: the method (GET — read without side effects), the path (the resource being accessed), the query string (filter and pagination parameters), headers (metadata about the request), and a body — empty here, since GET requests don't carry one.
What comes back
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1710720000
{
"count": 100,
"items": [ ... ],
"cursor": "eyJpZCI6InBheV94eHh4In0="
}The status line tells you at a glance whether the request succeeded; the rate-limit headers (Part 05) and the cursor field (Part 04) are both things this module comes back to build real logic around — they are not just decorative metadata.
HTTP methods — what each one means
| Method | Meaning | Has body? | Idempotent? |
|---|---|---|---|
| GET | Read a resource — no side effects | No | Yes — same result every time |
| POST | Create a new resource or trigger an action | Yes | No — creates something new each call |
| PUT | Replace a resource entirely | Yes | Yes — replaces to the same state |
| PATCH | Partially update specific fields | Yes | Usually yes |
| DELETE | Delete a resource | Rarely | Yes — deleting twice still succeeds |
Status codes and what your pipeline should do with each
| Code | Meaning | Pipeline action |
|---|---|---|
| 200 / 201 | Success — data in the response body | Process the data |
| 202 Accepted | Request received, processing async | Poll for the result |
| 400 Bad Request | Your request is malformed | Log and send to DLQ — do not retry |
| 401 Unauthorized | Credentials missing or invalid | Alert — do not retry |
| 404 Not Found | Resource does not exist | Log a warning, may have been deleted |
| 429 Too Many Requests | Rate limit exceeded | Back off and retry (Part 05) |
| 5xx | Something failed on their end | Retry with exponential backoff |
REST vs GraphQL vs gRPC
Most vendor APIs a data engineer ingests from are REST. Recognising the other two prevents confusion when a documentation page doesn't look like standard REST at all.
| Aspect | REST | GraphQL | gRPC |
|---|---|---|---|
| Request shape | HTTP GET/POST per resource | One POST endpoint, query in the body | Binary Protobuf over HTTP/2 |
| Over-fetching | Common — returns all fields | None — you specify exact fields | None — schema defines exact fields |
| Common examples | Stripe, Salesforce, GitHub REST | Shopify Admin, GitHub GraphQL v4 | Google Cloud APIs |
Authentication — Every Pattern a Data Engineer Encounters
Any API that is not fully public requires proof your code is allowed to access it. Four patterns cover almost everything you'll meet in practice — recognising which one an API uses on sight is most of the battle.
Pattern 1 — API Key
The simplest pattern: a static string, sent with every request.
import os, requests
API_KEY = os.environ['GATEWAY_API_KEY'] # never hardcode
response = requests.get(
'https://api.payment-gateway.example.com/v1/payments',
headers={'Authorization': f'Bearer {API_KEY}'},
)Some APIs use their own header name instead of the standard Authorization, and a rarer, less secure option puts the key directly in the URL:
# A custom header (check the vendor's docs for the exact name):
requests.get(url, headers={'X-API-Key': API_KEY})
# Query parameter — avoid when you have a choice: keys end up in
# server access logs and browser history, not just request headers:
requests.get(url, params={'api_key': API_KEY}).env in .gitignore), use different keys per environment, rotate periodically, restrict the key's permissions to only what the pipeline actually needs (read-only where possible), and watch the provider's usage dashboard for anything unexpected.Pattern 2 — OAuth 2.0
OAuth 2.0 is the standard for delegated authorisation — your pipeline gets a limited, time-boxed token instead of ever seeing a real password. It's required for APIs serving user-specific data: Salesforce, Google Analytics, HubSpot. The variant a data pipeline uses most is Client Credentials — server-to-server, no human involved.
import requests, time
def fetch_token(token_url, client_id, client_secret, scope=''):
response = requests.post(
token_url,
data={
'grant_type': 'client_credentials',
'client_id': client_id,
'client_secret': client_secret,
'scope': scope,
},
timeout=30,
)
response.raise_for_status()
return response.json(){
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 3600,
"token_type": "Bearer"
}expires_in is seconds until the token goes stale — 3600 here, one hour. Requesting a fresh token on every single API call would work, but wastes a full network round trip each time. A small manager class caches the token and only refreshes it once it's actually close to expiring:
class OAuth2ClientCredentials:
def __init__(self, token_url, client_id, client_secret, scope=''):
self.token_url, self.client_id = token_url, client_id
self.client_secret, self.scope = client_secret, scope
self._token = None
self._expires_at = 0
def get_token(self):
if self._token and time.time() < self._expires_at - 60:
return self._token # cached, with a 60s safety buffer
data = fetch_token(self.token_url, self.client_id, self.client_secret, self.scope)
self._token = data['access_token']
self._expires_at = time.time() + data.get('expires_in', 3600)
return self._token
def auth_header(self):
return {'Authorization': f'Bearer {self.get_token()}'}The 60-second buffer matters: without it, a token could expire mid-request — between the check and the API actually receiving it — causing an intermittent 401 that's hard to reproduce.
The other OAuth variant, Authorization Code, is for accessing a specific user's data and needs a browser in the loop once: the user logs in and approves access, your app receives a code, and exchanges it for an access token plus a long-lived refresh token. The part your pipeline actually automates is using that refresh token to get new access tokens indefinitely, with no user involved again:
def refresh_access_token(refresh_token, client_id, client_secret):
response = requests.post(
'https://auth.salesforce.com/services/oauth2/token',
data={
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': client_id,
'client_secret': client_secret,
},
)
response.raise_for_status()
return response.json() # a new access_token (sometimes a new refresh_token too)Pattern 3 — HMAC Signature
HMAC signs each request with a shared secret instead of sending a token at all — the server recomputes the signature and compares. Used by AWS, and by most providers for verifying incoming webhooks (Part 06).
import hmac, hashlib, time
def sign_request(method, path, body, secret):
timestamp = str(int(time.time()))
string_to_sign = f'{timestamp}\n{method.upper()}\n{path}\n{body}'
signature = hmac.new(
secret.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha256,
).hexdigest()
return {'X-Timestamp': timestamp, 'X-Signature': signature}Verifying an incoming signature (what you do inside a webhook handler) uses the same math in reverse:
def verify_webhook_signature(payload_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode('utf-8'), payload_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)hmac.compare_digest, never expected == signature_header. A plain equality check exits the moment it finds the first mismatched character — which means the exact time the comparison takes leaks information about how much of the signature was correct. An attacker measuring response times can use that to guess a valid signature one byte at a time. compare_digest always takes the same time no matter where (or whether) the strings differ.Pattern 4 — JWT (JSON Web Tokens)
A JWT is a self-contained token — three base64 pieces joined by dots (header.payload.signature) that encode claims like user ID and expiry directly in the token itself, no server-side lookup needed to check them.
import base64, json, time
def decode_jwt_payload(token):
parts = token.split('.')
payload_b64 = parts[1] + '=' * (4 - len(parts[1]) % 4) # restore stripped base64 padding
return json.loads(base64.urlsafe_b64decode(payload_b64))
def is_jwt_expired(token, buffer_seconds=60):
exp = decode_jwt_payload(token).get('exp')
return exp is not None and time.time() > (exp - buffer_seconds)decode_jwt_payload above and print the result. Seeing the claims come out as a plain dict — no library, no server call — makes the "self-contained token" idea click immediately.Pagination — Three Styles, and Which One to Actually Use
No API returns a million records in one response. Every data engineer who pulls from APIs meets all three pagination styles, and they fail in genuinely different ways — worth understanding before you pick one.
Style 1 — Offset/limit
# ?page=3&limit=100 == SELECT * FROM payments LIMIT 100 OFFSET 200Simple to reason about, and it's the only style that lets you jump straight to "page 50 of 100." It has two real problems on a live dataset, though. First, performance: an OFFSET 50000 forces the database to read and discard 50,000 rows just to reach your page — pages get slower the deeper you go. Second, and more dangerous, correctness: if a new record is inserted while you're mid-pagination, every offset after it silently shifts by one, and a record that would have been on page 2 quietly disappears from your results with no error at all.
def fetch_all_offset(base_url, headers, limit=100):
page = 1
while True:
resp = requests.get(base_url, headers=headers, params={'page': page, 'limit': limit})
resp.raise_for_status()
items = resp.json().get('items', [])
if not items:
break
yield from items
if len(items) < limit:
break
page += 1Style 2 — Cursor pagination
Instead of a position, the API hands back an opaque cursor pointing at a specific record — typically its ID or timestamp, base64-encoded. The next request sends that cursor back, and the API executes something closer to WHERE id > cursor_value ORDER BY id LIMIT 100 — an index lookup, not a scan-and-discard. Both of offset's problems disappear: a new insertion elsewhere in the table doesn't shift where your cursor points, and the lookup stays fast no matter how deep you are.
def fetch_all_cursor(url, headers, params, checkpoint_path=None):
cursor = load_checkpoint(checkpoint_path) # None on a fresh start
while True:
request_params = {**params, **({'cursor': cursor} if cursor else {})}
resp = requests.get(url, headers=headers, params=request_params, timeout=30)
resp.raise_for_status()
data = resp.json()
items = data.get('items', [])
yield from items
cursor = data.get('cursor')
save_checkpoint(checkpoint_path, cursor) # survives a mid-run crash
if not cursor or not items:
breakThat checkpoint save is what makes cursor pagination genuinely resumable, not just faster: if the process crashes on page 4,000, the next run reads the saved cursor and picks up exactly there — it does not silently restart from page 1 and re-fetch everything.
Style 3 — Next-URL / Link header
Some APIs (GitHub, most Django REST Framework services) hand you the entire next request as a ready-made URL, either in the body or in a standardised Link header — you don't construct the next request at all, you just follow it.
import re
def parse_link_header(link_header):
if not link_header:
return {}
return dict(re.findall(r'<([^>]+)>;\s*rel="([^"]+)"', link_header))
def fetch_all_next_url(start_url, headers):
url = start_url
while url:
resp = requests.get(url, headers=headers, timeout=30)
resp.raise_for_status()
data = resp.json()
yield from data.get('items', [])
url = data.get('next') or parse_link_header(resp.headers.get('Link')).get('next')| Style | Best for | Watch out for |
|---|---|---|
| Offset/limit | Small, static datasets; jumping to a specific page | Slow at deep pages; skips/duplicates on live data |
| Cursor | Large or actively-changing datasets — the default choice | Cannot jump to an arbitrary page; cursors may expire |
| Next-URL / Link header | APIs that hand you the whole next request already-built | Format varies — body field vs Link header, check the docs |
Rate Limiting — Staying Under Quota Instead of Just Reacting to 429
Every production API caps how many requests you can make per second, minute, or day. The right approach is two layers: proactive throttling that stays under the limit, and reactive handling for the occasional 429 that gets through anyway.
Reading what the API is already telling you
Recall the response headers from Part 02 — X-RateLimit-Remaining is not just informational, it's the input to a real decision:
def check_rate_limit_headers(response):
limit = response.headers.get('X-RateLimit-Limit')
remaining = response.headers.get('X-RateLimit-Remaining')
reset = response.headers.get('X-RateLimit-Reset')
if limit and remaining and int(remaining) < int(limit) * 0.1:
wait = max(0, int(reset) - int(time.time())) if reset else 5
print(f'Approaching rate limit — waiting {wait}s for window reset')
time.sleep(wait + 1)Handling a 429 that happens anyway
def handle_rate_limit_response(response):
retry_after = response.headers.get('Retry-After')
if retry_after:
try:
return float(retry_after) # most common: seconds
except ValueError:
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
retry_dt = parsedate_to_datetime(retry_after) # rarer: an HTTP date
return max(0, (retry_dt - datetime.now(timezone.utc)).total_seconds())
return 5.0 # no header at all — a sane defaultProactive throttling — a token bucket
Reacting to 429s is a safety net, not a strategy — a well-behaved pipeline should rarely trigger one at all. A token bucket smooths this out: a bucket holds a fixed number of tokens, refills at a steady rate, and every call consumes one token, waiting if none are available.
import threading
class TokenBucketRateLimiter:
def __init__(self, calls_per_second, burst_size=None):
self.rate = calls_per_second
self.capacity = burst_size or int(calls_per_second)
self.tokens = float(self.capacity)
self.last_refill = time.monotonic()
self._lock = threading.Lock()
def acquire(self):
while True:
with self._lock:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.last_refill) * self.rate)
self.last_refill = now
if self.tokens >= 1.0:
self.tokens -= 1.0
return
time.sleep(1.0 / self.rate / 2)Call limiter.acquire() immediately before every API request. If tokens are available it returns instantly; if not, it blocks just long enough for the bucket to refill — the pipeline naturally paces itself to the rate you configured, instead of firing requests as fast as possible and hoping.
calls_per_second=2 and call acquire() in a tight loop 10 times with a timestamp printed each time. You'll see the calls naturally space themselves roughly 0.5s apart — the bucket enforcing the rate without you writing any explicit sleep logic yourself.Webhooks vs Polling — And Why Production Systems Use Both
Polling means your pipeline regularly asks "anything new?" Webhooks mean the API calls you the moment something happens.
| Dimension | Polling | Webhooks |
|---|---|---|
| Latency | Minutes to hours, depending on interval | Near-real-time — seconds |
| Reliability | You control exactly when you pull | Delivery is not guaranteed by the provider |
| Effort | A scheduled script | A public HTTPS endpoint you must run |
| Best for | Batch pipelines, no webhook support | Real-time events like payment confirmations |
A production webhook handler, one requirement at a time
A real webhook receiver has four jobs, in this exact order:
if not verify_webhook_signature(body, signature, WEBHOOK_SECRET):
raise HTTPException(status_code=401)Your endpoint is a public URL anyone can send a request to. Skipping this step means a malicious actor can send a fake payment.captured event and have your pipeline treat an unpaid order as paid.
if event_id in processed_event_ids:
return {'status': 'already_processed'} # still 200 — do not reprocessThis matters because of requirement 3 below: providers retry deliveries, so the same event can arrive more than once, by design.
background_tasks.add_task(process_event, event)
return {'status': 'accepted'}def process_event(event):
if event.get('event') == 'payment.captured':
write_payment_to_db(event['payload']['payment']['entity'])
elif event.get('event') == 'order.paid':
update_order_status(event['payload']['order']['entity'])The hybrid pattern — webhooks are not enough alone
Webhook delivery is not guaranteed — if your server was down during a provider's retry window, that event is simply gone. The production pattern pairs webhooks (for low latency) with an hourly reconciliation poll (for completeness):
def reconcile_missed_payments(lookback_hours=2):
from_ts = int(time.time()) - lookback_hours * 3600
to_ts = int(time.time())
new_count = 0
for payment in fetch_all_cursor(PAYMENTS_URL, auth_header(), {'from': from_ts, 'to': to_ts}):
if upsert_payment(payment): # upsert returns True only for a genuinely new row
new_count += 1
print(f'Reconciliation: {new_count} payments recovered')Because upsert_payment is idempotent (Part 07 covers exactly why), running this on a 2-hour lookback every hour is safe even though it re-checks payments the webhook path already processed — anything already recorded is a no-op update, not a duplicate.
Writing a Parser That Survives the API Changing Under You
APIs evolve. Providers add fields, rename them, and occasionally change a field's type entirely. A pipeline that works today can break silently next month when a vendor ships a new version — unless the parser was written defensively from the start.
| Versioning | Looks like | Impact |
|---|---|---|
| URL (/v1/, /v2/) | /v1/payments vs /v2/payments | Old URL keeps working until deprecated — you control migration timing |
| Header | API-Version: 2026-03-01 | Must send it explicitly; omitting it silently uses a default that can change |
| No versioning | One URL, "backward compatible" changes | Riskiest — a provider can add a field safely or change a type unsafely |
The specific field that trips up almost every real payment integration is the amount: some providers return integer cents, others return a float in dollars, and some return either depending on payment method. Handle it once, in one place:
from decimal import Decimal, InvalidOperation
def parse_amount(raw):
if raw is None:
return None
try:
if isinstance(raw, int):
return Decimal(raw) / 100 # integer cents → dollars
return Decimal(str(raw).replace(',', '.')) # float, string, or European comma
except InvalidOperation:
return None>>> parse_amount(3800) # Stripe-style integer cents
Decimal('38.00')
>>> parse_amount(38.00) # a float already in dollars
Decimal('38.00')
>>> parse_amount("38,00") # a European-formatted string
Decimal('38.00')Timestamps have the same problem — Unix seconds, Unix milliseconds, and ISO 8601 strings are all common, sometimes from the very same API depending on the endpoint:
from datetime import datetime, timezone
def parse_timestamp(raw):
if raw is None:
return None
if isinstance(raw, (int, float)):
ts = raw / 1000 if raw > 1e10 else raw # >1e10 means milliseconds, not seconds
return datetime.fromtimestamp(ts, tz=timezone.utc)
if isinstance(raw, str) and ('T' in raw or 'Z' in raw):
return datetime.fromisoformat(raw.replace('Z', '+00:00'))
return NoneWith both helpers in place, the actual record parser reads as a flat, honest mapping — every field handled defensively, nothing assumed:
def parse_payment(raw):
return {
'payment_id': raw.get('id') or raw.get('payment_id'),
'amount': parse_amount(raw.get('amount')),
'currency': raw.get('currency', 'USD'),
'status': (raw.get('status') or '').lower() or None,
'created_at': parse_timestamp(raw.get('created_at') or raw.get('created')),
'_raw': raw, # keep the original — never silently discard unknown data
}That last field, _raw, is a habit worth keeping even once a pipeline feels stable: when the provider eventually adds a field you'll want later, it's already sitting in every historical row, instead of lost forever from records ingested before you noticed.
def detect_schema_changes(sample, expected_fields):
seen_fields = {k for record in sample for k in record}
new_fields = seen_fields - expected_fields
if new_fields:
print(f'WARNING: API is returning new fields not in schema: {new_fields}')Assembling Everything Into One Real Ingestion Pipeline
Every piece so far has been in isolation. Now they combine into the actual FreshCart payment-ingestion pipeline this module has been building toward — five properties stacked in order: authenticated, rate-limited, resumable, defensive, and idempotent.
Step 1 — configuration and structured logging
import os, json, time, logging, uuid
from pathlib import Path
API_BASE = 'https://api.payment-gateway.example.com/v1'
DLQ_PATH = Path('/data/dlq/payments.ndjson')
RUN_ID = str(uuid.uuid4())
logging.basicConfig(level=logging.INFO)
log = logging.getLogger('payment_ingestion')RUN_ID gets attached to every log line for this run — when three pipeline runs overlap in a shared log file at 2 AM, this is what lets you filter to just the one that failed.
Step 2 — an authenticated fetch with retry, rate limiting, and backoff
limiter = TokenBucketRateLimiter(calls_per_second=8) # stay under a 10/s API limit
def api_get(path, params, max_retries=5):
url = f'{API_BASE}{path}'
for attempt in range(1, max_retries + 1):
limiter.acquire()
resp = requests.get(url, headers=auth_header(), params=params, timeout=30)
if resp.status_code == 200:
check_rate_limit_headers(resp)
return resp.json()
elif resp.status_code == 429:
time.sleep(handle_rate_limit_response(resp))
elif resp.status_code in (500, 502, 503, 504):
time.sleep(min(60, 2 ** attempt))
else:
resp.raise_for_status() # a 4xx — do not retry, something is genuinely wrong
raise RuntimeError(f'API call failed after {max_retries} attempts')Notice this function is really just Part 02's status-code decision table, Part 03's auth_header(), and Part 05's rate limiting and backoff — expressed as code, nothing new.
Step 3 — paginated fetch with a checkpoint
def fetch_payments(from_ts, to_ts):
checkpoint_file = Path(f'/data/checkpoints/payments_{from_ts}_{to_ts}.json')
cursor = json.loads(checkpoint_file.read_text())['cursor'] if checkpoint_file.exists() else None
while True:
params = {'from': from_ts, 'to': to_ts, 'count': 100, **({'cursor': cursor} if cursor else {})}
data = api_get('/payments', params)
yield from data.get('items', [])
cursor = data.get('cursor')
if cursor:
checkpoint_file.write_text(json.dumps({'cursor': cursor}))
if not cursor or not data.get('items'):
break
checkpoint_file.unlink(missing_ok=True) # clean up only on a full, successful runStep 4 — parse defensively, route failures to a dead-letter queue
def parse_payment_safe(raw):
try:
record = parse_payment(raw) # from Part 07
if record['amount'] is None or record['amount'] < 0:
raise ValueError(f"invalid amount: {raw.get('amount')}")
return record
except Exception as e:
with open(DLQ_PATH, 'a') as f:
f.write(json.dumps({'error': str(e), 'record': raw}) + '\n')
log.warning('Record sent to DLQ: %s', e)
return NoneThe dead-letter queue is what makes a bad record a Tuesday-afternoon investigation instead of a failed pipeline run at 6 AM — one malformed row gets logged and skipped, and the other 47,999 good rows still load on schedule.
Step 5 — idempotent writes
from psycopg2.extras import execute_values
def upsert_batch(records, conn):
rows = [(r['payment_id'], float(r['amount']), r['currency'], r['status'], r['created_at']) for r in records]
with conn.cursor() as cur:
execute_values(cur, """
INSERT INTO silver.payments (payment_id, amount, currency, status, created_at)
VALUES %s
ON CONFLICT (payment_id) DO UPDATE SET status = EXCLUDED.status, updated_at = NOW()
""", rows)
conn.commit()
return len(rows)ON CONFLICT (payment_id) DO UPDATE is the entire idempotency guarantee in one line: running this pipeline twice for the same date, or hitting the same record via both the webhook path and the reconciliation poll, produces the same end state either way — never a duplicate row.
Step 6 — put it together, with a fixed (not relative) time window
def run(run_date):
log.info('Pipeline started for %s (run_id=%s)', run_date, RUN_ID)
dt = datetime.strptime(run_date, '%Y-%m-%d').replace(tzinfo=timezone.utc)
from_ts, to_ts = int(dt.timestamp()), int((dt + timedelta(days=1)).timestamp())
loaded, skipped, batch = 0, 0, []
with psycopg2.connect(os.environ['DATABASE_URL']) as conn:
for raw in fetch_payments(from_ts, to_ts):
parsed = parse_payment_safe(raw)
if parsed is None:
skipped += 1
continue
batch.append(parsed)
if len(batch) >= 5000:
loaded += upsert_batch(batch, conn)
batch = []
if batch:
loaded += upsert_batch(batch, conn)
log.info('Pipeline complete: loaded=%d skipped=%d', loaded, skipped)from_ts/to_ts are computed from the run_date argument, not from "right now." That single choice is what makes the whole pipeline idempotent at the extraction level too — running it three times for 2026-03-17 always asks the API for exactly the same window, whether it's 6 AM or 6 PM when you run it.Onboarding a Second Vendor — From Documentation to Production
FreshCart has just signed with a new delivery partner, ShipFast, and you're asked to ingest their daily delivery performance data. The payment pipeline just built is not reusable code here — it's a reusable process, applied to an entirely different API.
Step 1 — read the docs with a DE lens
Auth: API key in a header — simple. Rate limit: 500 requests per minute — comfortable. Pagination: cursor-based — good, the same pattern from Part 04. Webhooks: available, for status changes.
Step 2 — test with curl before writing any code
curl -s -H "X-API-Key: $SHIPFAST_API_KEY" \
"https://api.shipfast.io/v2/deliveries?date=2026-03-17&limit=5"{
"data": [ ... ],
"pagination": { "cursor": "eyJpZCI6MTI...", "has_more": true, "total": 48234 }
}One request, and three of Part 04's exact concepts are already confirmed: cursor-based, a total field for validating counts, and a shape close enough to the payment API that the same fetch_all_cursor pattern applies with almost no changes.
Step 3 — identify the data quality risks
The amount field is sometimes an integer, sometimes a float, exactly like Part 07's parse_amount was built to handle. delivered_at is null for undelivered orders. agent_id refers to ShipFast's internal IDs, not FreshCart's — three things the parser handles defensively, reusing Part 07's exact helpers.
Steps 4–6 — build small, backfill, then layer on webhooks
Run for one day first, and compare the API's total against rows actually written — any mismatch means a pagination or parsing bug, caught immediately rather than in production. Backfill 90 days of history with the same checkpointed loop from Part 08's Step 3, so a crash on day 47 resumes from day 47, not day 1. Finally, register a webhook endpoint for status changes, reusing Part 06's four-step handler (verify, dedupe, respond, process) and Part 06's hourly reconciliation job to catch anything missed.
Total time from task assignment to production: about two days. Every step reused a pattern already built for the payment pipeline — only the field names, endpoint URLs, and specific quirks changed.
Five Misconceptions About Working with APIs
5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Every API call is an HTTP request. 2xx means success, 4xx means your request is wrong (do not retry as-is), 5xx means retry with backoff. 429 specifically means back off and retry using the Retry-After header.
- ✓API keys are static strings from environment variables, never hardcoded. OAuth 2.0 Client Credentials is for server-to-server access; Authorization Code is for user-specific data via a third party. HMAC signs requests with a shared secret and is how you verify incoming webhooks.
- ✓Cursor pagination is the correct default for production: stable under concurrent writes, fast at any depth, and genuinely resumable via a saved checkpoint. Offset pagination degrades on both performance and correctness as a live dataset grows.
- ✓Rate limiting needs two layers: a proactive token bucket that paces requests below the limit, and reactive handling (Retry-After, exponential backoff, jitter) for the 429s that get through anyway.
- ✓Webhooks are low-latency but not guaranteed — always pair them with periodic reconciliation polling. A webhook handler must verify the signature (hmac.compare_digest), respond 200 immediately, and only then process, with an idempotency check to absorb the resulting duplicate deliveries.
- ✓Write defensive parsers: .get() with defaults for every field, and explicit handling for every real-world format a field might arrive in (integer cents vs float dollars, Unix seconds vs milliseconds vs ISO 8601). Keep the raw record alongside the parsed one — it is what saves you when a field you need shows up after the fact.
- ✓A production pipeline is idempotent (upserts on a business key), resumable (a saved pagination checkpoint), and computes its time window from the run date, never from "now" — that one choice is what makes reruns produce identical results instead of silently different ones.
- ✓Always test a new API with curl before writing a line of pipeline code — confirm auth works, read the pagination style and rate-limit headers, and inspect a real sample response. Ten minutes here prevents hours of debugging a misunderstood API later.
What comes next
Module 19 covers working with files at scale — partitioning strategies, compression trade-offs, the small file problem, and how columnar formats like Parquet store and retrieve data internally.
Module 19 → Working with Files at ScaleDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.