invitation.dev / docs / rest

partner REST API

A plain-HTTP projection of the same procedures the MCP tools call. The vocabulary follows the market — partners, commissions, programs, /track/sale — so an existing Rewardful, Tolt, FirstPromoter, or dub integration ports by changing the base URL and the key.

This surface is gated behind FEATURE_AGENT_API and returns 404 everywhere when it is off. Verify against your target deployment before wiring a production job to it.

# base url

https://api3.invitation.codes/api/partner/v1

Every response is JSON. Lists share one envelope — { data, has_more, next_cursor } — and every error shares one shape, { error: { code, message, doc_url } }. Switch on error.code, never on the message text.

# authentication

Bearer, or Basic if that is what you have

Mint a key in the business console under settings → API keys. Scopes are enforced per route: agent issues links, write records conversions, org:campaigns reads and manages a program. A key never gains permissions by coming in over REST rather than MCP.

bearer.sh
curl https://api3.invitation.codes/api/partner/v1/me \
  -H "Authorization: Bearer $INVITATION_API_KEY"
basic.sh
# The API key may also ride as the HTTP Basic username,
# which is what Rewardful/PartnerStack integrations already send.
curl -u "$INVITATION_API_KEY:" https://api3.invitation.codes/api/partner/v1/me

# reference

Endpoints

methodpathwhat it doesscope
GET/meKey introspection — owner, scopes, org binding, earn totals. Start here.any key
GET/programsCampaigns this key can administer. `program` is our campaign.org:campaigns
GET/partnersReferrers on a program. Filter by `status` and `email`.org:campaigns
POST/partnersProvision partners. Upserts on identity — safe to re-run. Batch via `partners: [...]`.write
GET/partners/:idOne referrer, by our id or `ext_<your-id>`.org:campaigns
GET/commissionsConversions and what they earned. `voided` is accepted for `rejected`.org:campaigns
POST/linksMint a tracked, disclosed referral link. Idempotent per key + program.agent
GET/linksSame primitive as a read, for clients that reach for GET first.agent
POST/track/leadRecord a non-revenue conversion (signup, trial, activation).write
POST/track/saleRecord a purchase. `revenue_cents` required.write

GET https://api3.invitation.codes/api/partner/v1 returns this list as JSON, so curl against the base URL is self-documenting rather than a 401.

OpenAPI 3.1

The API serves its own spec — point a client generator at it rather than hand-writing types. It needs no key, and servers[0].url reflects whichever deployment answered, so a spec fetched from staging targets staging.

openapi.sh
curl https://api3.invitation.codes/api/partner/v1/openapi.json

# Generate a typed client from it, e.g.:
npx openapi-typescript https://api3.invitation.codes/api/partner/v1/openapi.json -o partner-api.d.ts

# partners

Provision your own customers

Turn customers you already have into referrers without asking them to create an Invitation account first. Each one gets a referral code and a participant URL you can drop into your own dashboard or an email.

partners.sh
# One partner. Re-posting the same identity returns the
# existing row with "created": false — safe to re-run.
curl -X POST https://api3.invitation.codes/api/partner/v1/partners \
  -H "Authorization: Bearer $INVITATION_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "program": "summer-launch",
    "external_id": "cus_1001",
    "email": "alex@example.com"
  }'

# Or a batch, in one round trip.
curl -X POST https://api3.invitation.codes/api/partner/v1/partners \
  -H "Authorization: Bearer $INVITATION_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "program": "summer-launch",
    "partners": [
      { "external_id": "cus_1001", "email": "alex@example.com" },
      { "email": "pri@example.com", "referral_code": "VIPPRI" }
    ]
  }'

Upsert, not insert

Identity is `email` or `external_id` — post the same one twice and you get the existing partner back with created: false rather than a 409. That makes this safe to call from a nightly sync that does not track what it has already sent.

# conversions

Record what happened

Capture session_token from the browser tracker on the click, then report the conversion from your server. /track/sale requires revenue_cents; /track/lead is for signups and other non-revenue events.

sale.sh
curl -X POST https://api3.invitation.codes/api/partner/v1/track/sale \
  -H "Authorization: Bearer $INVITATION_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "session_token": "sess_abc",
    "event_id": "order_1042",
    "revenue_cents": 12900,
    "currency": "USD",
    "test_mode": true
  }'
lead.sh
curl -X POST https://api3.invitation.codes/api/partner/v1/track/lead \
  -H "Authorization: Bearer $INVITATION_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "session_token": "sess_abc",
    "event_id": "signup_884",
    "customer_email": "buyer@example.com",
    "test_mode": true
  }'

event_id is your idempotency key

It is required, and it must be stable across retries — reuse your own order or event id. A replay returns 409 duplicate_event rather than double-crediting, so a retrying job is safe by construction. Rewards credit on conversions only, never on clicks.

Rehearse with test_mode

Send test_mode: true to exercise the whole path — attribution, validation, response shape — without crediting rewards or firing merchant webhooks. Do this before pointing a production job at the endpoint.

# pagination

Cursors, not page numbers

List routes take limit (default 20, max 100) and starting_after. Treat next_cursor as opaque: pass back exactly what you were given. Stop when has_more is false.

paginate.sh
# Page through partners. Cursors are opaque — pass back
# exactly what next_cursor returned, never construct one.
curl "https://api3.invitation.codes/api/partner/v1/partners?program=summer-launch&limit=50" \
  -H "Authorization: Bearer $INVITATION_API_KEY"

# {
#   "data": [ ... 50 rows ... ],
#   "has_more": true,
#   "next_cursor": "50"
# }

# errors

One shape, always

409 Conflict
{
  "error": {
    "code": "duplicate_event",
    "message": "Duplicate eventId — conversion conv_1 already recorded.",
    "doc_url": "https://invitation.dev/docs/conversions#idempotency"
  }
}
statuscodewhen
400invalid_requestA required field is missing or malformed.
401unauthorizedNo key, or the key is not recognised.
403forbiddenThe key is valid but lacks the scope, or is bound to another org.
404not_foundNo such resource — or the partner API is feature-gated off.
409duplicate_eventThis `event_id` was already recorded for the program.
412precondition_failedNot enrolled, or the program failed the agent safety policy.
429rate_limitedPer-key rate limit exceeded.

A 404 on every route usually means the surface is feature-gated off on that deployment, not that you got the path wrong — check FEATURE_AGENT_API before debugging your client.

# directory

Public reads need no key

The public catalog — programs, codes, users, shops, platform counts — lives on https://api3.invitation.codes/api/v2/* and is readable without credentials. @invitation/node covers it alongside the write path, so one package serves both; a key is optional and only raises rate limits.

reads.ts
import { InvitationNodeClient } from "@invitation/node";

// Public directory reads need no key — a key only raises rate limits.
const invitation = new InvitationNodeClient();

const featured = await invitation.programs.featured({ country: "US", limit: 5 });
const codes = await invitation.codes.listByProgram("notion");
const counts = await invitation.stats.publicCounts();