Enrich restaurants with ORA in Codex
This guide turns a CSV of restaurant names and addresses into an enriched CSV with POS provider, delivery providers, online reservations, and verified website, ready for any CRM or spreadsheet. The agent doing the work is Codex. If you use Claude Code, the same guide exists for Claude Code; the API is identical, only the agent changes.
You need an ORA API key. Get one in three curl commands: Get an ORA API key, self-serve. Every response shown below is a real captured API response, not a mock.
Point Codex at the docs
Codex writes correct integration code when the API contract is in its context before it starts. Two ways to do that:
Per session. Open your session with:
Read https://www.248.ai/llms.txt and https://www.248.ai/docs before calling the ORA API.
Per project. Add a block to your project's AGENTS.md so every session starts with the contract. This block is Codex's equivalent of the reusable /ora skill our docs recommend for skill-capable agents:
## ORA API
- Host https://ora.248.ai. Auth header X-API-Key on every request.
- Key lives at ~/.cache/248/ora_api_key. Load with API_KEY=$(cat ~/.cache/248/ora_api_key). Never print or commit it.
- Account and credits endpoints use /api/v1; enrichment endpoints use /v1.
- Reads are free. POST /v1/enrichment/jobs costs 1 credit per accepted job; ask before creating jobs.
- Docs: https://www.248.ai/docs and https://www.248.ai/llms.txt
The contract
Three things Codex must know before it spends your credits:
- Inputs are
nameandaddressonly. There is no input for website or Google Place ID. ORA resolves the Google listing itself and returnsgoogle_place_idandwebsiteas outputs in every result. - Reads are free, jobs are not.
GET /api/v1/account/credits,GET /v1/enrichment/fields, job status, and results cost nothing.POST /v1/enrichment/jobscosts 1 credit per accepted job. - The flow is create, poll, fetch. Create a job, poll its status until it reaches a terminal state, fetch the result. There is no other flow.
Check the balance and the field catalog first, both free:
API_KEY=$(cat ~/.cache/248/ora_api_key)
curl -sS "https://ora.248.ai/api/v1/account/credits" -H "X-API-Key: $API_KEY"
curl -sS "https://ora.248.ai/v1/enrichment/fields" -H "X-API-Key: $API_KEY"
The catalog currently lists 51 fields, 49 of them requestable. This guide requests four: pos, online_ordering_delivery_providers, online_reservations, website.
The loop, on one restaurant
The input CSV, restaurants.csv:
name,address
Boka,"1729 N Halsted St, Chicago, IL 60614"
Small Cheval,"1732 N Milwaukee Ave, Chicago, IL 60647"
Lou Malnati's Pizzeria,"439 N Wells St, Chicago, IL 60654"
Walk the first row by hand before scripting the rest. Create the job:
curl -sS -X POST "https://ora.248.ai/v1/enrichment/jobs" \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{
"name": "Boka",
"address": "1729 N Halsted St, Chicago, IL 60614",
"fields": ["pos", "online_ordering_delivery_providers", "online_reservations", "website"],
"idempotency_key": "blog-2026-07-22-boka-tool-guide"
}'
The real response:
{
"job_id": "b5a0346d-33c9-4534-becc-77710e01a699",
"status": "pending",
"requested_fields": [
"pos",
"online_ordering_delivery_providers",
"online_reservations",
"website"
],
"production_run_id": "b5a0346d-33c9-4534-becc-77710e01a699",
"created_at": "2026-07-22T23:23:33.392819Z"
}
Poll until the status is completed, failed, or cancelled:
curl -sS "https://ora.248.ai/v1/enrichment/jobs/b5a0346d-33c9-4534-becc-77710e01a699" \
-H "X-API-Key: $API_KEY"
Honest wait times from our runs: when ORA already had recent data for the location, jobs finished in under 10 seconds. Fresh single-field runs took 30 seconds to 3 minutes. Budget up to 15 minutes for a fresh multi-field job before investigating, and poll every 10 seconds. Jobs keep running server-side, so a client that disconnects loses nothing; poll again later.
Then fetch the result:
curl -sS "https://ora.248.ai/v1/enrichment/jobs/b5a0346d-33c9-4534-becc-77710e01a699/result" \
-H "X-API-Key: $API_KEY"
The real response, truncated where marked with ...:
{
"job_id": "b5a0346d-33c9-4534-becc-77710e01a699",
"status": "completed",
"name": "Boka",
"address": "1729 N Halsted St, Chicago, IL 60614",
"website": "http://www.bokachicago.com/",
"google_place_id": "ChIJg7aSMj3TD4gRlHUmzqlebUI",
"fields": {
"pos": {
"status": "skipped_existing",
"value": {
"reasoning": "Boka is a Michelin-starred fine dining restaurant operating on a reservation-only basis with tasting menus. The restaurant website (bokachicago.com) and the Boka Restaurant Group dining page show no direct online ordering system or POS provider evidence. While third-party delivery platforms like order.online and DoorDash list the restaurant, these are marketplace/delivery platforms, not POS systems. No evidence of Toast, SpotOn, Clover, Square, or other POS providers was found in the restaurant's website, search results, or ordering infrastructure.",
"confidence": "low",
"primary_pos": null,
"evidence_url": null,
"secondary_providers": []
},
"source_workflow": "pos-v2",
"error": null
},
"online_ordering_delivery_providers": {
"status": "skipped_existing",
"value": [
"Boka",
"DoorDash"
],
"source_workflow": "online-ordering-v1",
"error": null
},
"online_reservations": {
"status": "skipped_existing",
"value": true,
"evidence_url": "https://www.yelp.com/biz/boka-chicago",
"source_workflow": "online-reservations-v1",
"error": null
},
"website": {
"status": "skipped_existing",
"value": "http://www.bokachicago.com/",
"evidence_url": "https://bokachicago.com/contact",
"source_workflow": "website-verification-v2",
"error": null
}
},
...
}
Two things to notice. The top level carries the resolved identity: google_place_id and website arrive even though you never sent them. And every field is an evidence object: status, value, reasoning, evidence_url, source_workflow. The value is the answer; the evidence is why you should believe it.
Read per-field statuses, not just the job status
The job status tells you when to stop polling: it is pending or running while work is in flight, and terminal at completed, failed, or cancelled. What each field actually did is in the field's own status:
| Field status | Meaning | Has a value? |
|---|---|---|
completed | The workflow ran and produced a result | Yes |
skipped_existing | ORA already had current data for this field and returned it | Yes |
no_data | The workflow ran and found nothing for this restaurant | No |
pending / running | Not finished yet; poll again | Not yet |
skipped | The workflow did not run; error says why | No |
failed | The workflow errored; error says why | No |
Every Boka field above says skipped_existing with a populated value: ORA had enriched this location recently and returned the existing data. Treat it exactly like completed.
The status your agent must handle deliberately is skipped with "error": "google_match_unmatched". We hit it for real while writing this guide. We sent "name": "Portillo's Hot Dogs" for 100 W Ontario St, and that name did not match the Google listing at that address. The job still completes; the fields just come back empty:
{
"gift_cards": {
"status": "skipped",
"value": null,
"reasoning": null,
"source_workflow": "gift-cards-v2",
"error": "google_match_unmatched"
}
}
The top-level google_place_id and website are null in that result too. The fix is to send the restaurant's name as it appears on its Google listing and retry. Note that the job was accepted and billed, so sloppy names cost credits: have your agent flag unmatched rows instead of silently retrying variations.
Idempotency keys make retries safe
Pass an idempotency_key with every create. If the request is retried with the same key, ORA returns the existing job instead of creating and billing a new one. We verified this: re-sending the Boka create above with the same key returned the same job_id (with its current status, completed) and the credit balance did not move.
Derive the key from the row, not from the timestamp, so a crashed run resumes cleanly:
idempotency_key = "enrich-" + slug(name) + "-" + slug(address)
Save raw responses before flattening
Flattening loses information: reasonings, evidence URLs, per-field statuses. When a value looks wrong next week, the raw response is how you check what the API actually said. The rule for your agent: write the full result JSON to disk first, then flatten from the file, never from memory.
The whole thing, scripted
This is the script Codex should end up with, stdlib only. It checks the balance before spending, creates one job per row with a stable idempotency key, polls to terminal, saves the raw response, then flattens:
import csv
import json
import pathlib
import re
import sys
import time
import urllib.request
HOST = "https://ora.248.ai"
KEY_PATH = pathlib.Path.home() / ".cache" / "248" / "ora_api_key"
API_KEY = KEY_PATH.read_text().strip()
FIELDS = ["pos", "online_ordering_delivery_providers", "online_reservations", "website"]
RAW_DIR = pathlib.Path("raw")
TERMINAL = {"completed", "failed", "cancelled"}
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
headers = {"X-API-Key": API_KEY}
if body is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(HOST + path, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read())
def slug(text):
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
def enrich(name, address):
job = call("POST", "/v1/enrichment/jobs", {
"name": name,
"address": address,
"fields": FIELDS,
"idempotency_key": f"enrich-{slug(name)}-{slug(address)}",
})
job_id = job["job_id"]
deadline = time.time() + 900
while True:
status = call("GET", f"/v1/enrichment/jobs/{job_id}")
if status["status"] in TERMINAL:
break
if time.time() > deadline:
raise SystemExit(f"job {job_id} still {status['status']} after 15 minutes")
time.sleep(10)
result = call("GET", f"/v1/enrichment/jobs/{job_id}/result")
RAW_DIR.mkdir(exist_ok=True)
(RAW_DIR / f"{job_id}.json").write_text(json.dumps(result, indent=2))
return result
def flatten(result):
fields = result["fields"]
pos = fields.get("pos", {})
pos_value = pos.get("value") or {}
delivery = fields.get("online_ordering_delivery_providers", {}).get("value") or []
reservations = fields.get("online_reservations", {}).get("value")
return {
"name": result["name"],
"address": result["address"],
"google_place_id": result.get("google_place_id") or "",
"website": result.get("website") or "",
"pos_primary": pos_value.get("primary_pos") or "",
"pos_confidence": pos_value.get("confidence") or "",
"pos_status": pos.get("status") or "",
"delivery_providers": "|".join(delivery),
"online_reservations": "" if reservations is None else str(reservations).lower(),
}
def main():
with open("restaurants.csv", newline="") as f:
todo = list(csv.DictReader(f))
if not todo:
raise SystemExit("restaurants.csv has no rows")
balance = call("GET", "/api/v1/account/credits")["credits_balance"]
if balance < len(todo):
raise SystemExit(f"{len(todo)} rows but only {balance} credits; each accepted job costs 1 credit")
rows = []
for line in todo:
result = enrich(line["name"], line["address"])
rows.append(flatten(result))
print(f"{line['name']}: done", file=sys.stderr)
with open("enriched.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
if __name__ == "__main__":
main()
The script enriches rows one at a time, which is the right shape for a small CSV. For hundreds of rows, have Codex restructure it to create all jobs first and then poll them as a batch; the API contract does not change.
The Boka row of enriched.csv, from the real response above (remaining rows omitted):
name,address,google_place_id,website,pos_primary,pos_confidence,pos_status,delivery_providers,online_reservations
Boka,"1729 N Halsted St, Chicago, IL 60614",ChIJg7aSMj3TD4gRlHUmzqlebUI,http://www.bokachicago.com/,,low,skipped_existing,Boka|DoorDash,true
...
An empty pos_primary next to confidence: low is the honest answer for a reservation-only fine dining room with no visible ordering infrastructure. The reasoning in the raw file explains exactly why, which is what separates an empty cell you can trust from an empty cell you have to re-research.
Paste-ready prompt for Codex
Give Codex this block and the restaurants.csv from above, and it does everything this guide covered:
Enrich a CSV of restaurants with the ORA API. Input: restaurants.csv with columns name,address. Output: enriched.csv plus one raw JSON file per restaurant in raw/.
Host https://ora.248.ai. Auth header X-API-Key on every request. The key is in ~/.cache/248/ora_api_key; load it with API_KEY=$(cat ~/.cache/248/ora_api_key) and never print, echo, or commit it. Account and credits endpoints use the /api/v1 prefix; enrichment endpoints use /v1.
Free reads: GET /api/v1/account/credits, GET /v1/enrichment/fields, GET /v1/enrichment/jobs/{job_id}, GET /v1/enrichment/jobs/{job_id}/result. Spending: POST /v1/enrichment/jobs costs 1 credit per accepted job. Check the balance first and stop and ask me if it is lower than the row count.
For each row: POST /v1/enrichment/jobs with {"name": ..., "address": ..., "fields": ["pos", "online_ordering_delivery_providers", "online_reservations", "website"], "idempotency_key": "enrich-<slug of name>-<slug of address>"}. Inputs are name and address only; ORA resolves the Google listing and returns google_place_id and website in the result. Retrying a create with the same idempotency_key returns the existing job instead of billing a new one, so reuse the key on any retry.
Poll GET /v1/enrichment/jobs/{job_id} every 10 seconds until status is completed, failed, or cancelled (give up at 15 minutes). Then GET /v1/enrichment/jobs/{job_id}/result and save the full response unmodified to raw/{job_id}.json before touching it.
Flatten from the saved file. Each entry in fields is an object with status, value, reasoning, evidence_url, source_workflow. Statuses: completed and skipped_existing carry a value; no_data means the workflow ran and found nothing; skipped and failed carry an error saying why (google_match_unmatched means the name did not match the Google listing at that address; flag the row for me instead of retrying name variations, since every accepted job costs a credit); pending and running mean keep polling. For the CSV: the pos value is an object, take primary_pos and confidence; join list values with |; write booleans lowercase. Never invent values for missing fields; leave the cell empty and record the field status.
Next: the field guides for POS provider, Menu, and Gift cards, or the same walkthrough for Claude Code.