Skip to main content

Oppy API — reference

Base URL https://api.oppyone.com/api/public/v1. Every endpoint except /health needs a key — see Getting started for authentication, scopes, rate limits and error shapes.

Examples below use the environment set up there:

export OPPY_KEY="oppy_live_..."
export OPPY="https://api.oppyone.com/api/public/v1"

Cards

GET /cards

Scope: cards:read

Your workspace's cards, best first — hot, then warm, then cold, then evergreen, and within each tier the strongest signals first. The first page holds the most valuable cards in your workspace, so page for volume rather than for quality.

Use card_status=pending for the inbox, and approved_family for everything approved or later — the same set the dashboard's My Approvals tab shows.

ParameterNotes
card_statusRepeatable. pending, approved, contacted, replied, meeting_booked, closed, rejected, or approved_family for all five post-approval statuses.
card_typeRepeatable. hot, warm, cold, evergreen. Narrows the set; it does not reorder it.
signal_typeRepeatable, e.g. hiring-trends, news, reddit-mentions, linkedin-post-company, g2-product-reviews, website-intelligence, work-milestone, twitter-contact, linkedin-post-contact.
created_from, created_toISO dates, inclusive.
limitDefault 25, max 100. Above 100 returns 422 rather than silently capping.
offsetDefault 0. Latency is flat to the end of the set.
curl -s "$OPPY/cards?card_status=pending&limit=2" \
-H "Authorization: Bearer $OPPY_KEY"
{
"data": [
{
"id": "9a69c44b-e8d4-44d7-8e46-897aa99a6c13",
"card_status": "pending",
"signal_type": "hiring-trends",
"card_type": "warm",
"title": "Northwind is hiring across GTM",
"trigger_text": null,
"relevance_text": null,
"strategy_text": null,
"hydrated": false,
"company_name": "Northwind",
"company_website": "northwind.example",
"company_linkedin_url": null,
"company_country": "United States",
"company_industries": ["Software Development"],
"source_url": "https://...",
"assigned_to": "sam@your-company.example",
"created_at": "2026-07-29T09:14:02",
"visible_from": null
}
],
"pagination": { "limit": 2, "offset": 0, "total": 17930, "has_more": true }
}

Most cards arrive as shells. trigger_text, relevance_text and strategy_text are null until the card is hydrated. assigned_to is an email — Oppy never exposes internal user ids.

GET /cards/{card_id}

Scope: cards:read

One card, same shape. No side effects — it will never hydrate or charge anything.

If hydrated is true the text is already here. You do not need to call hydrate — that is the whole purpose of the flag. Read the card, check it, and only hydrate when it is false.

POST /cards/{card_id}/hydrate

Scope: cards:write

Generates the narrative fields. Takes no body. Idempotent — on an already-hydrated card it returns immediately with the same text and makes no model call.

curl -s -X POST "$OPPY/cards/$CARD/hydrate" \
-H "Authorization: Bearer $OPPY_KEY"
{
"id": "9a69c44b-e8d4-44d7-8e46-897aa99a6c13",
"hydrated": true,
"trigger_text": "Composite hiring signal (1/3 evidence) fired 2026-05-22, hiring a CRO/Sales role",
"relevance_text": "They match your ICP on size and stack, and a CRO hire usually precedes outbound scaling...",
"strategy_text": "Nurture the Head of Sales/Founder with a light-touch note on how a CRO hire often precedes outbound scaling...",
"...": "all other card fields unchanged"
}

Two speeds, rate-limited differently. Most cards already carry a pre-generated insight and hydrate in a few seconds with no model call — those get the generous limit. Cards without one need real generation and get a tight limit. You do not choose; Oppy decides per card. Watch X-RateLimit-Limit on the response to see which applied: 120 means it was free, 20 means it cost a generation.

POST /cards/{card_id}/approve

Scope: cards:write

Moves the card to approved and starts the contact lookup for that company. Reveals nothing and spends no credits. Takes no body.

curl -s -X POST "$OPPY/cards/$CARD/approve" -H "Authorization: Bearer $OPPY_KEY"
# -> the card object with "card_status": "approved"

POST /cards/{card_id}/reject

Scope: cards:write

Body is optional. A reason feeds your ICP refinement, so it is worth sending.

curl -s -X POST "$OPPY/cards/$CARD/reject" \
-H "Authorization: Bearer $OPPY_KEY" -H "Content-Type: application/json" \
-d '{"reason": "not our ICP - too small", "category": "wrong_size"}'
# -> the card object with "card_status": "rejected"

PATCH /cards/{card_id}

Scope: cards:write

Move a card along your pipeline, or reassign it.

curl -s -X PATCH "$OPPY/cards/$CARD" \
-H "Authorization: Bearer $OPPY_KEY" -H "Content-Type: application/json" \
-d '{"card_status": "contacted", "assigned_to": "sam@your-company.example"}'

Accepts contacted, replied, meeting_booked, closed, and an assigned_to email from GET /users — or null to unassign. It rejects approved and rejected with a 422; those have their own endpoints because they do more than set a field.

Contacts

GET /cards/{card_id}/contact-candidates

Scope: contacts:read · Free

Who could be revealed at this card's company. Names and titles only — email and phone are withheld until you pay for one.

curl -s "$OPPY/cards/$CARD/contact-candidates" -H "Authorization: Bearer $OPPY_KEY"
[
{
"id": "d32f25a4-f1fa-4656-93b3-58059292f01a",
"full_name": "Alex",
"title": "Founder and CEO",
"email": null,
"phone": null,
"linkedin_url": null,
"revealed": false
},
{
"id": "ef2db80c-dd82-4c50-962b-baecc9318e0a",
"full_name": "Gabriella",
"title": "Sales Professional",
"revealed": false
}
]

An empty list usually means the card is not approved yet. Approving triggers the lookup, which takes about a minute. If another card for the same company was approved earlier, candidates are there immediately. Poll this endpoint after approving — it costs nothing.

The id here is the candidate_id you pass to reveal. Surnames appear only after the reveal, which is why the first name alone is shown.

POST /cards/{card_id}/contacts/reveal

Scope: contacts:write · Spends credits

1 credit for email and LinkedIn, 5 more if you ask for the phone. Revealing the same person again costs nothing.

Body fieldNotes
candidate_idRequired for signal cards — pick one from contact-candidates. Omit for intent cards, which carry one known contact.
include_phoneDefault false. True adds 5 credits.
curl -s -X POST "$OPPY/cards/$CARD/contacts/reveal" \
-H "Authorization: Bearer $OPPY_KEY" -H "Content-Type: application/json" \
-d '{"candidate_id": "d32f25a4-f1fa-4656-93b3-58059292f01a", "include_phone": false}'
{
"contact": {
"id": "c35fddcc-67f9-4aeb-a73f-2199c195dbf7",
"full_name": "Alex Rivera",
"title": "Founder and CEO",
"email": "alex.rivera@northwind.example",
"phone": null,
"linkedin_url": "https://linkedin.com/in/...",
"revealed": true
},
"credits_used": 1,
"credits_remaining": 82
}

Revealing approves the card. If it was still in your inbox, paying for a contact moves it to approved — spending a credit is a stronger signal than clicking approve, so the card should not stay pending.

With include_phone set to true the response carries a phone value and "credits_used": 6. If no phone is found you are not charged for one.

GET /cards/{card_id}/contacts

Scope: contacts:read · Free

Contacts already paid for on this card, with full details. Use this to re-read what you own without spending anything.

[
{
"full_name": "Alex Rivera",
"email": "alex.rivera@northwind.example",
"phone": "+1 555 0100",
"revealed": true
}
]

CRM

POST /cards/{card_id}/push-to-crm

Scope: crm:write

Creates the contact and company in your CRM and attaches a note carrying the card's context. Goes to every CRM you have connected; add ?provider=attio or ?provider=hubspot to target one. Takes no body.

curl -s -X POST "$OPPY/cards/$CARD/push-to-crm" -H "Authorization: Bearer $OPPY_KEY"
{
"card_id": "02319a25-caca-42aa-9a97-6755f74bdbc2",
"results": [
{
"provider": "attio",
"status": "pushed",
"person_record_id": "6c4f56bb-3627-4c19-b12c-d84a3bbea9d0",
"company_record_id": "7593548b-3133-4ac0-a195-e2d2cb1a184c",
"note_id": "5e52b1df-3460-40fb-b57b-e4a765fa4913",
"person_web_url": "https://app.attio.com/your-workspace/person/6c4f56bb-...",
"company_web_url": "https://app.attio.com/your-workspace/company/7593548b-...",
"hs_contact_id": null,
"error": null
},
{
"provider": "hubspot",
"status": "failed",
"hs_contact_id": null,
"error": "HubSpot token is invalid or revoked; reconnect HubSpot in the dashboard"
}
]
}

Hydrate before you push. The CRM note carries the card's trigger and strategy. Push an unhydrated card and the note arrives with those sections empty — the challenges and buying signals still land, but the narrative does not.

Requires a revealed contact. Without one you get a 400 telling you to reveal first. Nothing is charged.

Per-provider results. pushed, queued or failed. queued is a success — a HubSpot outage is retried automatically rather than dropped. One CRM failing never hides a push that succeeded in the other; the whole call fails with a 502 only when every CRM does. The example above returned 200 with Attio succeeding and a revoked HubSpot token failing.

Ownership follows whoever the card is assigned to, if that person exists in your CRM — a user in HubSpot, the oppy_owner attribute in Attio. It is set when the record is created and never reassigned by a later push. Pushing the same card twice upserts; it does not duplicate.

Workspace

EndpointReturnsScope
GET /workspaceid, name, website, industryworkspace:read
GET /usersemail, name, status — the emails valid for assigned_toworkspace:read
GET /creditsused, limit, remainingworkspace:read
GET /health{"status": "ok"} — no key needed

Support & Troubleshooting