Tutorial · Get started
Make your first API call
Go from API key to a working script that prints your sales pipeline — with stops for pagination, writes, and error handling along the way.
Last updated Jul 14, 2026
By the end of this tutorial you’ll have a small script that pages through your deals and prints your open pipeline with a total. You’ll get there in steps that each teach one thing: prove your credentials work, read the response envelope, page through a list, create a record, and see what errors actually look like.
Everything up to the final script is plain curl, so you can follow
along in any terminal. The script itself comes in Node 22 and Python
flavors — pick one.
What you’ll need
- A OnePageCRM account you can sign in to.
curl.- Node 22 or Python 3 with
requestsfor the final script.
1. Get your credentials
Sign in to OnePageCRM and open the API settings page:
https://app.onepagecrm.com/app/api
On the Configuration tab you’ll find the two values the API uses for HTTP Basic auth:
| Value | Used as |
|---|---|
user_id | HTTP Basic username |
api_key | HTTP Basic password |
The api_key grants full access to your account — treat it like a
password. Export both as environment variables so they never end up in
your shell history or your code:
export ONEPAGECRM_USER_ID="your-user-id"
export ONEPAGECRM_API_KEY="your-api-key"
2. Prove they work
The base URL for the REST API is https://app.onepagecrm.com/api/v3.
The best smoke test is GET /bootstrap.json — it returns account-wide
reference data (statuses, deal stages, custom field schemas) and
confirms your credentials in one call:
curl -u "$ONEPAGECRM_USER_ID:$ONEPAGECRM_API_KEY" \
https://app.onepagecrm.com/api/v3/bootstrap.json
If you get JSON back, you’re authenticated. If you get a 401, jump
ahead to break it on purpose — that section
shows you exactly what a 401 looks like and how to fix it.
3. List your contacts
curl -u "$ONEPAGECRM_USER_ID:$ONEPAGECRM_API_KEY" \
https://app.onepagecrm.com/api/v3/contacts.json
Every response — this one and every other — arrives in the same envelope:
{
"status": 0,
"message": "OK",
"timestamp": 1781100000,
"data": {
"contacts": [
{ "id": "5f...", "first_name": "Ada", "last_name": "Lovelace", "...": "..." }
],
"total_count": 34,
"page": 1,
"per_page": 10,
"max_page": 4
}
}
Three things to internalize now, because every later step relies on them:
status: 0means success. Non-zero means an error.datacarries the payload, shaped per endpoint.- List endpoints add pagination metadata:
total_count,page,per_page,max_page.
4. Page through
In the response above, total_count is 34 but only 10 contacts came
back. That’s the default page size. Pagination is controlled with two
query parameters:
| Parameter | Default | Max |
|---|---|---|
per_page | 10 | 100 |
page | 1 | — |
So to fetch everything with the fewest requests, ask for 100 per page
and walk page from 1 to max_page:
curl -u "$ONEPAGECRM_USER_ID:$ONEPAGECRM_API_KEY" \
"https://app.onepagecrm.com/api/v3/contacts.json?page=2&per_page=100"
When page equals max_page, you’ve seen everything. That loop —
fetch, append, check max_page — is the heart of the final script.
5. Create a contact
Reads use GET; writes use POST with a JSON body:
curl -u "$ONEPAGECRM_USER_ID:$ONEPAGECRM_API_KEY" \
-X POST https://app.onepagecrm.com/api/v3/contacts.json \
-H "Content-Type: application/json" \
-d '{"first_name": "Ada", "last_name": "Lovelace"}'
The response uses the same envelope, and data echoes the new contact
back — including the id the server assigned. Save that id if you
want to update or delete the record later. (Ada is now a real contact
in your account — feel free to delete her in the app when you’re
done.)
6. Break it on purpose
You’ll hit errors in real integrations, so let’s meet the two most common ones now, while the stakes are zero.
A 401 — bad credentials. Re-run the contacts call with a
deliberately wrong key:
curl -i -u "$ONEPAGECRM_USER_ID:wrong-key" \
https://app.onepagecrm.com/api/v3/contacts.json
The -i flag shows the HTTP status line: 401 Unauthorized. The body
carries five fields:
{
"status": 400,
"message": "Invalid auth token",
"error_name": "invalid_auth_token",
"error_message": "Authorization token is invalid",
"errors": {}
}
Notice the body says "status": 400 while the HTTP status line says
401. The body’s status is an API error code, and it can differ
from the HTTP status — trust the HTTP status line for control flow.
Of the five fields, error_name is the stable identifier to branch on
in code, error_message is the human-readable text to log, and
errors holds per-field details on validation failures. The full
catalog is on API errors.
When you see a 401, the fix is almost always the same: re-copy
user_id and api_key from the
API settings page.
A validation error — bad write. Now send a contact with no fields at all:
curl -i -u "$ONEPAGECRM_USER_ID:$ONEPAGECRM_API_KEY" \
-X POST https://app.onepagecrm.com/api/v3/contacts.json \
-H "Content-Type: application/json" \
-d '{}'
The request is well-formed and authenticated, but the payload fails
validation, so the API rejects it — again with error_name and
error_message telling you what to fix. The lesson for your code:
check the HTTP status, and when it’s not a 2xx, surface
error_message instead of swallowing the body.
7. The script: print your pipeline
Time to assemble the pieces. The script below pages through
GET /deals.json (the same loop from step 4, same envelope from
step 3, same error handling from step 6), keeps the deals whose
status is pending, and prints them with a total.
Node 22 — save as pipeline.mjs, no dependencies needed:
// pipeline.mjs — prints every open deal in your pipeline
const { ONEPAGECRM_USER_ID: user, ONEPAGECRM_API_KEY: key } = process.env;
const auth = "Basic " + Buffer.from(`${user}:${key}`).toString("base64");
const base = "https://app.onepagecrm.com/api/v3";
async function getPage(page) {
const res = await fetch(`${base}/deals.json?page=${page}&per_page=100`, {
headers: { Authorization: auth },
});
const body = await res.json();
if (!res.ok) {
throw new Error(
`HTTP ${res.status}: ${body.error_name} — ${body.error_message}`,
);
}
return body.data;
}
const deals = [];
let page = 1;
let maxPage = 1;
do {
const data = await getPage(page);
deals.push(...data.deals);
maxPage = data.max_page;
page += 1;
} while (page <= maxPage);
const open = deals.filter((d) => d.status === "pending");
for (const d of open) {
console.log(`${d.name.padEnd(40)} ${d.amount}`);
}
const total = open.reduce((sum, d) => sum + (d.amount ?? 0), 0);
console.log(`\n${open.length} open deals worth ${total}`);
node pipeline.mjs
Python — the same loop with requests:
# pipeline.py — prints every open deal in your pipeline
import os
import requests
auth = (os.environ["ONEPAGECRM_USER_ID"], os.environ["ONEPAGECRM_API_KEY"])
base = "https://app.onepagecrm.com/api/v3"
deals, page, max_page = [], 1, 1
while page <= max_page:
res = requests.get(
f"{base}/deals.json",
auth=auth,
params={"page": page, "per_page": 100},
)
body = res.json()
if not res.ok:
raise SystemExit(
f"HTTP {res.status_code}: {body.get('error_name')} — {body.get('error_message')}"
)
deals += body["data"]["deals"]
max_page = body["data"]["max_page"]
page += 1
open_deals = [d for d in deals if d["status"] == "pending"]
for d in open_deals:
print(f"{d['name']:<40} {d['amount']}")
print(f"\n{len(open_deals)} open deals worth {sum(d['amount'] or 0 for d in open_deals)}")
python pipeline.py
Either way, the output is your live pipeline:
Acme renewal 12000
Globex onboarding 8500
Initech expansion 20000
3 open deals worth 40500
If it prints nothing, you have no pending deals — create one in the app and run it again.
Where to go next
- Query CRM data with OQL — the whole script
above collapses into one JSON query (
from: deals,where: { status: "pending" }), with sorting and aggregates built in — via the MCP server. - Subscribe to webhook events — get told when a deal changes instead of re-running the script.
- API reference — every endpoint, parameter, and response shape, with try-it-now requests in the browser.
- Get started with OAuth — when your integration needs to act on behalf of users other than yourself, swap Basic auth for OAuth 2.1.