How to Convert API Response JSON to CSV (Stripe, GitHub, Shopify, and More)
Real-world guide to converting API response JSON to CSV and Excel. Uses actual Stripe, GitHub, Shopify, and Twitter API response structures — not toy examples. Covers Python, online tools, and the specific nesting patterns each API uses.
- The core problem: APIs return hierarchical data, CSVs are flat
- Stripe API
- What a Stripe payment intent actually looks like
- Fetching and converting Stripe payments
- GitHub API
- Issues export
- Repository stars / contributors export
- Shopify API
- What a Shopify order actually looks like
- Option A: One row per order (order-level analysis)
- Option B: One row per line item (product-level analysis)
- Twitter / X API v2
- What a Twitter search response looks like
- Joining tweets to their authors
- The fast path: online converter
- The pagination problem
- Handling rate limits
- Frequently Asked Questions
- What's the easiest way to convert an API response to CSV without coding?
- My API response has a `data` wrapper — how do I get just the records?
- How do I export more than one page of results?
- My CSV has columns like `payment_method_details.card.brand` — can I rename them?
- Can I convert the API response to Excel (.xlsx) instead of CSV?
- Why do some fields come back empty in my CSV?
You've made an API call. You have the response — a wall of JSON with nested objects, arrays of arrays, and fields that only exist on some records. Now your manager wants it in a spreadsheet by 2pm.
Most tutorials show you how to convert [{"name": "Alice"}, {"name": "Bob"}]. Real APIs don't look like that. Stripe wraps everything in a data envelope. GitHub paginates across hundreds of pages. Shopify nests line items three levels deep. Twitter's v2 API separates users from tweets in a includes object that requires a join.
This guide uses actual API response structures — copied from the documentation and production responses — and shows you how to get each one into a flat CSV or Excel file.
The core problem: APIs return hierarchical data, CSVs are flat
Every API response you'll encounter falls into one of these shapes:
Shape 1 — Envelope with a data array (Stripe, most REST APIs)
{
"object": "list",
"data": [ {...}, {...}, {...} ],
"has_more": true,
"total_count": 843
}
The records you want are inside data. The top-level keys are metadata.
Shape 2 — Direct array (GitHub, simple REST endpoints)
[
{"id": 1, "login": "alice", ...},
{"id": 2, "login": "bob", ...}
]
Clean and simple. pandas reads this in one line.
Shape 3 — Records with nested sub-arrays (Shopify orders, Stripe invoices)
[
{
"order_id": "5001",
"line_items": [
{"sku": "SHOE-BLK-42", "quantity": 1, "price": 89.99},
{"sku": "SOCK-GRY-M", "quantity": 3, "price": 7.99}
]
}
]
Each record contains an array of sub-records. You have to decide: one row per order, or one row per line item?
Shape 4 — Side-loaded related objects (Twitter v2, JSON:API)
{
"data": [ {"id": "1234", "author_id": "567", "text": "..."} ],
"includes": {
"users": [ {"id": "567", "name": "Alice", "username": "alice"} ]
}
}
The main records reference related objects by ID. Getting a flat CSV requires a join.
The right conversion strategy depends on which shape you're dealing with. The sections below cover each major API.
Stripe API
Stripe is the hardest common case. Payments, customers, invoices, and subscriptions all have multiple nesting levels and optional fields that vary by payment method.
What a Stripe payment intent actually looks like
{
"id": "pi_3MtwBwLkdIwHu7ix28a3tqPD",
"object": "payment_intent",
"amount": 2000,
"currency": "usd",
"status": "succeeded",
"created": 1680729600,
"customer": "cus_NffrFeUfNV2Hib",
"payment_method_details": {
"card": {
"brand": "visa",
"last4": "4242",
"exp_month": 8,
"exp_year": 2026,
"country": "US"
},
"type": "card"
},
"metadata": {
"order_id": "6735",
"campaign": "summer_sale"
},
"charges": {
"object": "list",
"data": [
{
"id": "ch_3MtwBw2eZvKYlo2C0XjbNTen",
"amount": 2000,
"receipt_url": "https://pay.stripe.com/receipts/..."
}
]
}
}
The real Stripe response has 40+ fields. This is the important structure: payment_method_details is a nested object, metadata is a nested object, and charges is another envelope-wrapped list.
Fetching and converting Stripe payments
import stripe
import pandas as pd
import json
stripe.api_key = "sk_live_..."
# Fetch all payments (handles pagination automatically)
payments = []
page = stripe.PaymentIntent.list(limit=100)
while True:
payments.extend(page.data)
if not page.has_more:
break
page = stripe.PaymentIntent.list(limit=100, starting_after=payments[-1].id)
# Convert to dicts for json_normalize
records = [p.to_dict() for p in payments]
# Flatten — stop at level 2 to avoid exploding the charges sub-list
df = pd.json_normalize(records, sep=".", errors="ignore", max_level=2)
# Keep and rename the columns you care about
column_map = {
"id": "payment_id",
"amount": "amount_cents",
"currency": "currency",
"status": "status",
"created": "created_unix",
"customer": "customer_id",
"payment_method_details.card.brand": "card_brand",
"payment_method_details.card.last4": "card_last4",
"payment_method_details.card.country": "card_country",
"metadata.order_id": "order_id",
}
df = df.rename(columns=column_map)
df = df[[c for c in column_map.values() if c in df.columns]]
# Convert amount from cents to dollars
df["amount_usd"] = df["amount_cents"] / 100
# Convert unix timestamp
df["created_at"] = pd.to_datetime(df["created_unix"], unit="s").dt.strftime("%Y-%m-%d %H:%M UTC")
df = df.drop(columns=["amount_cents", "created_unix"])
df.to_csv("stripe_payments.csv", index=False, encoding="utf-8-sig")
print(f"Exported {len(df)} payments")
No Python? Paste the raw JSON response into the JSON to CSV converter. It flattens payment_method_details.card.brand and metadata.order_id into proper column names automatically. Use it for one-off exports; use the script above for recurring pulls.
GitHub API
GitHub's REST API returns clean arrays — the easiest shape to work with. The challenge is pagination (GitHub caps at 100 results per page) and rate limits (60 requests/hour unauthenticated, 5,000 with a token).
Issues export
[
{
"id": 1,
"number": 142,
"title": "Fix CSV export encoding on Windows",
"state": "open",
"created_at": "2024-01-15T10:22:00Z",
"closed_at": null,
"user": {
"login": "alice",
"id": 1001,
"type": "User"
},
"labels": [
{"id": 101, "name": "bug", "color": "d73a4a"},
{"id": 102, "name": "good first issue", "color": "7057ff"}
],
"comments": 3,
"assignees": [
{"login": "bob", "id": 1002}
]
}
]
user is a nested object. labels and assignees are arrays of objects. closed_at is null on open issues.
import requests
import pandas as pd
REPO = "owner/repo"
TOKEN = "ghp_..." # optional but recommended
headers = {"Authorization": f"token {TOKEN}"} if TOKEN else {}
issues = []
page = 1
while True:
resp = requests.get(
f"https://api.github.com/repos/{REPO}/issues",
params={"state": "all", "per_page": 100, "page": page},
headers=headers,
)
resp.raise_for_status()
batch = resp.json()
if not batch:
break
issues.extend(batch)
page += 1
# Respect rate limit headers
if int(resp.headers.get("X-RateLimit-Remaining", 1)) == 0:
print("Rate limit hit — wait 60s or add a token")
break
df = pd.json_normalize(issues, sep=".")
# Arrays of objects — join to readable strings
df["labels"] = df["labels"].apply(
lambda x: ", ".join(l["name"] for l in x) if isinstance(x, list) else ""
)
df["assignees"] = df["assignees"].apply(
lambda x: ", ".join(a["login"] for a in x) if isinstance(x, list) else ""
)
# Keep useful columns
keep = ["number", "title", "state", "created_at", "closed_at",
"user.login", "labels", "assignees", "comments"]
df = df[[c for c in keep if c in df.columns]]
df = df.rename(columns={"user.login": "author"})
df.to_csv(f"{REPO.replace('/', '_')}_issues.csv", index=False)
print(f"Exported {len(df)} issues")
For a quick export without writing code, fetch the first page of results in your browser at https://api.github.com/repos/owner/repo/issues?per_page=100, copy the JSON, and paste it into the JSON to CSV converter.
Repository stars / contributors export
The same pattern applies for other GitHub endpoints. Contributors endpoint returns a direct array:
resp = requests.get(f"https://api.github.com/repos/{REPO}/contributors", headers=headers)
df = pd.DataFrame(resp.json())[["login", "contributions", "type"]]
df.to_csv("contributors.csv", index=False)
Shopify API
Shopify orders are the canonical example of Shape 3: records with nested sub-arrays. An order has multiple line items, each with its own SKU, quantity, and pricing. How you flatten this depends on what you're trying to analyze.
What a Shopify order actually looks like
{
"id": 820982911946154500,
"order_number": 1001,
"email": "bob.norman@example.com",
"created_at": "2024-11-15T12:00:00-05:00",
"total_price": "199.98",
"financial_status": "paid",
"fulfillment_status": "fulfilled",
"customer": {
"id": 207119551,
"first_name": "Bob",
"last_name": "Norman",
"email": "bob.norman@example.com"
},
"shipping_address": {
"city": "Chestnut Hill",
"province": "Massachusetts",
"country": "United States",
"zip": "02467"
},
"line_items": [
{
"id": 866550311766439000,
"title": "IPod Nano - 8GB",
"sku": "IPOD2008PINK",
"quantity": 1,
"price": "199.00",
"vendor": "Apple",
"variant_title": "Pink"
}
],
"tags": "returning-customer, vip"
}
Option A: One row per order (order-level analysis)
import pandas as pd
import json
with open("shopify_orders.json") as f:
orders = json.load(f)
df = pd.json_normalize(orders, sep=".", errors="ignore", max_level=1)
# Summarize line items instead of exploding them
df["item_count"] = df["line_items"].apply(
lambda x: sum(item["quantity"] for item in x) if isinstance(x, list) else 0
)
df["skus"] = df["line_items"].apply(
lambda x: "|".join(item.get("sku", "") for item in x) if isinstance(x, list) else ""
)
df = df.drop(columns=["line_items"])
keep = ["order_number", "created_at", "total_price", "financial_status",
"fulfillment_status", "customer.email", "shipping_address.city",
"shipping_address.province", "item_count", "skus", "tags"]
df = df[[c for c in keep if c in df.columns]]
df.to_csv("shopify_orders.csv", index=False)
Option B: One row per line item (product-level analysis)
df_items = pd.json_normalize(
orders,
record_path="line_items",
meta=[
"order_number",
"created_at",
"total_price",
"financial_status",
["customer", "email"],
],
sep=".",
errors="ignore",
)
df_items.to_csv("shopify_line_items.csv", index=False)
This produces one row per purchased item, with the order details repeated on every row. Use this for revenue-by-product analysis, inventory reports, and return rate calculations.
Twitter / X API v2
Twitter's v2 API uses a "side-loaded" pattern — related objects are in a separate includes key rather than embedded in each tweet. This requires a manual join before you can produce a flat CSV.
What a Twitter search response looks like
{
"data": [
{
"id": "1346889436626259968",
"text": "Just shipped a new feature!",
"author_id": "2244994945",
"created_at": "2024-01-06T18:40:40.000Z",
"public_metrics": {
"retweet_count": 12,
"reply_count": 3,
"like_count": 47,
"quote_count": 2
}
}
],
"includes": {
"users": [
{
"id": "2244994945",
"name": "Twitter Dev",
"username": "TwitterDev",
"public_metrics": {
"followers_count": 571234,
"following_count": 2048
}
}
]
},
"meta": {
"newest_id": "1346889436626259968",
"result_count": 10,
"next_token": "abc123"
}
}
Joining tweets to their authors
import pandas as pd
import json
with open("twitter_response.json") as f:
response = json.load(f)
# Flatten tweets
tweets_df = pd.json_normalize(response["data"], sep=".")
# Flatten included users
users_df = pd.json_normalize(response["includes"]["users"], sep=".")
users_df = users_df.rename(columns={
"id": "author_id",
"name": "author_name",
"username": "author_username",
"public_metrics.followers_count": "author_followers",
})
users_df = users_df[["author_id", "author_name", "author_username", "author_followers"]]
# Join on author_id
df = tweets_df.merge(users_df, on="author_id", how="left")
# Clean up column names
df = df.rename(columns={
"public_metrics.retweet_count": "retweets",
"public_metrics.like_count": "likes",
"public_metrics.reply_count": "replies",
})
keep = ["id", "created_at", "text", "author_username", "author_name",
"author_followers", "retweets", "likes", "replies"]
df = df[[c for c in keep if c in df.columns]]
df.to_csv("tweets.csv", index=False)
print(f"Exported {len(df)} tweets")
The fast path: online converter
For one-off exports from any API, the JSON to CSV converter handles the most common cases without writing a line of code:
- Make your API call in a browser, Postman, Insomnia, or curl
- Copy the response JSON
- Paste it into the converter
- Download CSV or Excel
The converter automatically:
- Unwraps common envelope patterns (
data,items,results) - Flattens nested objects to dot-notation column names
- Joins primitive arrays (
tags,labels) into pipe-delimited strings - Handles missing fields across records
When the online converter is enough:
- You need a one-time export from Stripe, HubSpot, GitHub, or any other API
- The response is a flat array or one level of nesting
- You don't need to paginate (the first page has all the records you need)
When you need Python:
- You need to paginate through thousands of records
- You need to handle the side-loaded pattern (Twitter, JSON:API)
- You want to schedule the export to run automatically
- The response has 3+ levels of nesting that require custom logic
For everything in between, try the converter first. If the output looks wrong — missing columns, [object Object] values, or the wrong row structure — switch to the pandas approach for that specific API.
The pagination problem
Most APIs return 20–100 records per request. Getting everything requires following pagination. Each API does it differently:
| API | Pagination style | How to detect end |
|-----|-----------------|-------------------|
| Stripe | Cursor (starting_after) | has_more: false |
| GitHub | Page number | Empty array response |
| Shopify | Page info cursor | No pageInfo.hasNextPage |
| Twitter v2 | next_token | No next_token in meta |
| HubSpot | Cursor (after) | No paging.next in response |
A generic pagination loop:
import requests
def fetch_all_pages(url, params, auth_header, data_key="data", next_key="next"):
"""
Generic paginator for cursor-based APIs.
Adjust data_key and next_key for each API.
"""
records = []
while url:
resp = requests.get(url, params=params, headers=auth_header)
resp.raise_for_status()
body = resp.json()
# Handle both array root and envelope pattern
if isinstance(body, list):
records.extend(body)
break # Arrays don't paginate this way
else:
records.extend(body.get(data_key, []))
# Get next page URL or cursor
next_url = body.get(next_key) or body.get("meta", {}).get("next")
url = next_url
params = {} # Clear params — next URL already has them
return records
Handling rate limits
APIs throttle requests. Hitting a rate limit mid-export means incomplete data. Basic handling:
import time
import requests
def get_with_retry(url, headers, params, max_retries=3):
for attempt in range(max_retries):
resp = requests.get(url, headers=headers, params=params)
if resp.status_code == 429:
# Rate limited — check for Retry-After header
retry_after = int(resp.headers.get("Retry-After", 60))
print(f"Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
continue
resp.raise_for_status()
return resp
raise Exception(f"Failed after {max_retries} retries")
Frequently Asked Questions
What's the easiest way to convert an API response to CSV without coding?
Copy the JSON response from your browser, Postman, or curl and paste it into the JSON to CSV converter. It handles nested objects, missing fields, and primitive arrays automatically. Download CSV or Excel in one click.
My API response has a data wrapper — how do I get just the records?
Most converters unwrap common envelope patterns automatically. In Python: records = response["data"] before passing to pd.json_normalize. In the online converter, paste the full response — it detects and unwraps data, items, and results keys automatically.
How do I export more than one page of results?
You need to paginate using the API's cursor or page token. Each API is different — see the pagination table above for the pattern each major API uses. The Python examples in the Stripe and GitHub sections show working pagination loops.
My CSV has columns like payment_method_details.card.brand — can I rename them?
Yes. In pandas: df.rename(columns={"payment_method_details.card.brand": "card_brand"}). In Excel, rename the column headers after import. The online converter produces dot-notation column names by default — rename in your spreadsheet.
Can I convert the API response to Excel (.xlsx) instead of CSV?
Yes. Use the JSON to Excel converter for one-click .xlsx output. In Python, replace df.to_csv(...) with df.to_excel("output.xlsx", index=False, engine="openpyxl"). Excel files preserve date formatting and don't have the encoding ambiguity of CSV.
Why do some fields come back empty in my CSV?
APIs return optional fields only when they have a value. A payment without a refund won't have a refunded_at field at all — it simply won't appear in that record. pandas fills these with NaN (empty in CSV). This is correct behavior. Use df.fillna("") if you want explicit empty strings instead of NaN.