← Blog
technicalintegrations

How to run an in-app refer-and-earn program without a widget vendor

A refer-and-earn page is a link, a code, a few counts and a payout history. Build that page in your own app and keep attribution, commissions and payouts in an API underneath. Here's the headless setup on OpenPartner, call by call, including the parts that aren't built yet.

A common way to add a refer-and-earn page to a product is a widget vendor. You paste their script, their panel appears inside your app, and their dashboard keeps the ledger. That’s a fair trade when you need something live this week and have no front-end time to spare.

If you’re weighing one, check four things first: how far you can restyle the panel, what happens on mobile, whether referral data can sit next to your affiliate and creator data, and what you can export if you leave.

The alternative is headless. You build the referral page and connect your signup and billing events. OpenPartner keeps the record underneath: clicks, which customer referred whom, commissions, refunds and payouts. The page itself is small: a share link, a code, a few counts and a list of earnings.

This post walks through that setup on OpenPartner. It summarizes the headless referral guide, which has every request and error case, and the in-app referral sample, a dependency-free Node server plus one HTML page. The full API reference, generated from an OpenAPI 3.1 spec, is at app.openpartner.dev/api/docs.

The shape

customer's browser ──▶ your backend ──▶ OpenPartner API
(your page) (holds the key) (partners, links, codes, ledger)
friend clicks link ──▶ OpenPartner router ──▶ your signup page (?cref=<click id>)
your signup + billing code ──────────────────▶ identify, signup, invoice_paid, refunds

Your customers never sign in to OpenPartner and never see it. The browser talks only to your backend. The one exception is the browser SDK’s identify call on your signup page, which needs no key.

Set up once, with an admin key

Every path below is relative to your API base. On OpenPartner Cloud the slug goes after /api:

Terminal window
export OP=https://app.openpartner.dev/api/t/<your-slug>
export ADMIN=op_... # admin key: setup only, never deployed with your app

On Cloud, creating partners also needs an active plan (otherwise 402 plan_required) and an approved brand (otherwise 403 brand_pending_review). If you bill with Stripe and want friends’ discounts applied at Stripe Checkout, connect Stripe before you create any partners (see step 3). Then do these four things, before the first customer gets a referral account.

Keep customers out of the hosted partner portal. Every OpenPartner account has a hosted partner portal. In an in-app program, your page is the customer’s only view, so turn off portal access for partners created without an invite:

Terminal window
curl -s -X POST $OP/config/partner-access -H "Authorization: Bearer $ADMIN" \
-H 'content-type: application/json' -d '{"uninvitedPortalAccess": false}'

Stop offering the Network. OpenPartner runs a creator marketplace, the Network. Partners only join it if they opt in themselves, but a customer-referral program usually shouldn’t ask. POST /config/network with {"autoEnroll": false} stops asking.

Create a private program. A program holds the terms. This one pays the referrer $25 on a referred customer’s first payment and gives the friend 20% off for three months:

Terminal window
curl -s -X POST $OP/programs -H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' -d '{
"name": "Refer & earn",
"destinationUrl": "https://yourapp.example.com/signup",
"commissionRule": [
{ "trigger": "first", "eventType": "invoice_paid", "type": "fixed", "value": 25, "currency": "USD" }
],
"customerReward": { "type": "percent_off", "value": 20, "duration": "repeating", "durationInMonths": 3 },
"attributionWindowDays": 90,
"holdbackDays": 30,
"shareOnNetwork": false
}'
  • The $25 is a one-off rule, so it pays only when the first payment lands within the 90-day attribution window after the click. A recurring rule keeps paying past the window.
  • currency pins the reward to USD. Without it, a fixed amount is paid in the payment’s currency: a EUR invoice would earn €25.
  • holdbackDays: 30 approves each commission automatically 30 days after it accrues, so a refund inside that window cancels it before anyone is paid.
  • shareOnNetwork: false keeps the program off the public marketplace.

Mint a scoped key for runtime. Your app runs on this key, not the admin key:

Terminal window
curl -s -X POST $OP/api-keys/scoped -H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' \
-d '{"label":"in-app referral","scopes":["partners:create","partners:read","links:write","links:read","commissions:read","payouts:read","events:write"]}'

The scope that matters is partners:create. It can create partners without an invite and nothing else. It can’t email anyone, set commission terms, revoke or import. If a create leaves out sendInvite: false, the call fails with 403 forbidden_scope instead of emailing your customer. The broader partners:write is for admin tools. Give it to a separate job if you need it, for example one that revokes a customer who closes their account.

A scoped key can act on every partner in your account. Your backend must decide which partner a request is for from its own session, never from a parameter the browser sends.

1. Create a partner for each customer

When a customer first opens your referral page, create their partner from your backend. The snippets below use a small helper, op(method, path, body), that calls the API with the scoped key. The sample has a full version with error handling.

const partner = await op('POST', '/partners', {
email: user.email, // an address your app has verified
name: user.name,
sendInvite: false, // active at once, no email from OpenPartner
portalAccess: false, // can't sign in to the hosted portal
programIds: [PROGRAM_ID], // without this, they get every program you run
metadata: { appUserId: user.id },
});
// Store partner.id on your users table; later page views use it directly.

Emails are unique per account. A second create answers 409 email_taken. Look the partner up with GET /partners?email=, and only link it if its metadata.appUserId is this user.

If you’d rather not share customers’ real addresses, any unique address works, such as [email protected]. OpenPartner can never email an address like that. It still stores the name, the metadata you send, and the referral, commission and payout records your integration creates.

Creating a partner fires partner.created, then partner.activated with "invited": false and "source": "api". If a Zapier flow welcomes new partners on that webhook, filter those out so customers don’t get an affiliate welcome email.

The link. Create one share link on the referral program:

await op('POST', `/partners/${partner.id}/links`, { linkKey: 'ada-7f3a', programId: PROGRAM_ID });
// Share URL on OpenPartner Cloud: https://app.openpartner.dev/r/<your-slug>/ada-7f3a

linkKey is 3 to 64 letters, digits, _ or -, unique in your account. On 409 linkKey_taken, retry with another suffix. The share URL isn’t returned: you build it from your router base. With the white-label add-on, links are on your own domain.

The code. Granting a program mints a coupon code for the partner automatically, built from the email and four random characters: ADALOVELACE4F2A. A friend who uses it at checkout is credited to the referrer the same way as a click, and that friend’s later payments follow.

One gap to know about: reading a partner’s code (GET /partners/{id}/coupons) needs the admin key today. There’s no scoped read for codes yet. Read it once from a separate server-side job that holds the admin key, store it next to the partner id, and your runtime key never needs it.

3. Conversions

Capture the click. When a friend follows the link, the router records the click and redirects to your destinationUrl with ?cref=<click id>. The browser SDK on your landing and signup pages keeps it, and links it to the new user after signup:

import { OpenPartner } from '@openpartner/sdk';
const tracker = OpenPartner.init({ apiUrl: 'https://app.openpartner.dev/api/t/<your-slug>' });
await tracker.identify(user.id); // right after signup, and on login

identify works in the browser that holds the click. It links that click to your user id and attributes anything already recorded for that user. Calling it on login too covers a friend who clicked on their phone, signed up on a laptop, and later logs in on the phone. It can’t pay twice: an event that already credited a referrer keeps that credit.

Report signups and payments from your server, with an id you control on each one:

await op('POST', '/attribution/events', {
userId: 'user_456', type: 'invoice_paid', value: 49.0, currency: 'USD', externalEventId: invoice.id,
});

A retry with the same externalEventId replays instead of paying twice. Send payments as invoice_paid, with value as a number in major units (49.00, not 4900).

If you bill with Stripe, you can skip reporting payments. On OpenPartner Cloud, connect your Stripe account under Admin → Integrations (beta: until Stripe finishes reviewing the app, its install screen says “Test version”). Purchases, invoices, renewals and refunds are then recorded from Stripe. Stripe still has to know which customer is which:

  • create the Stripe Customer with metadata.openpartner_user_id set to the same user id you pass to identify(), before their first payment, or
  • pass the click as client_reference_id when your server creates a Checkout Session (the op.js script tag keeps it in an op_ref cookie for you).

Turn on promotion codes at checkout (allow_promotion_codes: true), and a friend’s code entered at Stripe Checkout credits the referrer with no call from you.

If your system only has the click id, for example a Stripe Payment Link with client_reference_id={click_id}, report by click with the Conversions API: POST /attribution/conversions with clickId, type, value and externalEventId. Its response tells you whether a referrer was credited, and if not, why.

Code redemptions outside Stripe, in your own checkout, are reported with the purchase itself. This call replaces the /attribution/events report for that payment, so don’t send both:

await op('POST', '/coupons/redeem', {
code: 'ADALOVELACE4F2A', userId: 'user_456', eventType: 'invoice_paid',
value: 39.2, currency: 'USD', externalEventId: invoice.id,
});

Refunds go to POST /attribution/refunds. conversionExternalEventId points at the payment, and externalEventId is the refund’s own id:

await op('POST', '/attribution/refunds', {
conversionExternalEventId: invoice.id, externalEventId: refund.id, amount: 19.0, // omit amount for a full refund
});

An unpaid commission is reversed, or reduced on a partial refund. One that was already paid is recorded as a clawback and taken off the referrer’s next payout. If a commission is in a payout that hasn’t settled, nothing changes yet: the response says held_in_transfer, and you retry the same request once the payout settles. Refunds that OpenPartner already recorded from Stripe don’t need reporting again.

4. Dual-sided rewards

Both sides of the reward live on the program you created above.

  • The referrer earns commission from commissionRule. A flat amount per paying friend is a first rule on invoice_paid. A share of revenue is a percent rule, with recurring and recurringMonths to cap it (“20% for 12 months”). Rules combine, so “$25 on the first payment plus 10% for a year” is two entries in the array.
  • The friend gets the customerReward. A percent_off or amount_off discount (the latter with a currency) runs for one invoice, every invoice, or the first N months. free_months gives that many months at 100% off.

With Stripe connected, each partner code on the program gets a matching coupon and promotion code in your Stripe account. The friend enters the code, Stripe applies the discount, and the referrer is credited. This is best effort: if Stripe wasn’t connected when a code was minted, or the Stripe call failed, the code still credits the referrer but has no Stripe discount behind it. Once Stripe is connected, mint a new code for that partner (Admin → Partners → Coupons, or the admin API), retire the old one, and store the new code. Without Stripe at all, your checkout has to honor the discount itself.

The honest limit: the discount rides on the code, not the link. A friend who arrives by link and never types the code is credited to the referrer, but gets no automatic discount. If you want the link alone to carry the discount, your checkout has to apply it. Link-based customer rewards aren’t built yet. Many in-app programs show the code next to the link for this reason.

5. Show stats and earnings

Four reads cover the page, all on the scoped key:

  • GET /partners/{id}/timeseries?since=&programId=&bucket=day: clicks, leads (signups and trials) and sales per day. These are attributed event counts, not unique customers. Without bucket=day, ranges over 90 days come back weekly.
  • GET /partners/{id}/dashboard?since=: totals with commissions by status.
  • GET /partners/{id}/commissions: the commission ledger, newest first, paginated.
  • GET /partners/{id}/payouts: the 200 most recent payouts.

The timeseries and dashboard default to the last 30 days, so pass since for lifetime figures.

Show three money figures. Pending is commissions accrued or approved. Processing is commissions marked paid whose payout is still pending. Paid out is payouts with status paid, the only money that has actually gone out. Never add amounts in different currencies together. Because the payouts read stops at 200, a referrer with a long history needs payout totals kept in your own database.

Cache these responses. All API calls from one IP share a limit of 600 requests per five minutes, event reporting included. Your backend calls from one IP, so that’s the budget for your whole integration. The sample caches each user’s stats for five minutes and serves the cached copy on a 429.

6. Payouts

Headless referrers have no Stripe Connect account, so they’re paid on the manual rail. OpenPartner works out what’s owed, and you send the money. Running and confirming payouts are admin operations: use the admin portal, or the admin key from a back-office job, never the runtime key.

  1. Commissions approve when the holdback ends (or when you approve them).
  2. The payout run, weekly by default or POST /payouts/run, groups approved commissions into a pending payout per referrer and currency.
  3. You pay the customer: account credit in your billing, PayPal, a bank transfer or a gift card.
  4. You confirm it with POST /payouts/{id}/confirm, and it becomes paid.

To pay cash, collect the customer’s PayPal email or bank details in your own app and save them with PUT /partners/{id}/payout-profile, using a key with payout_profiles:write. Details are encrypted at rest. Each payout run can then produce a PayPal batch file, from the admin portal or with the admin key, that you upload to your own PayPal account. A Wise file exists too, but its bank columns are still being checked against Wise’s own template, and it doesn’t cover INR yet. The money goes from your account to your customer and never passes through OpenPartner.

OpenPartner doesn’t run a gift-card catalog or send money on your behalf on this rail. If you want a vendor to handle the payout itself, compare the referral tools that do.

7. Portal access: on or off

Off is the usual choice for in-app programs, and it’s what the setup above does. A partner without portal access:

  • gets nothing from the portal’s sign-in form, which answers the same way it does for an email it doesn’t recognise, so it can’t reveal who your customers are;
  • can’t be invited, and gets no email from OpenPartner unless you ask (for example "notify": true on a revoke);
  • still earns, and is still paid, exactly as before.

Turn it on, per partner, when the hosted portal is useful. A customer who starts referring at affiliate volume might want the full dashboard, postbacks and their own payout details page. Use PATCH /partners/{id} with {"portalAccess": true} (a partners:write or admin key), or the Portal column on Admin → Partners. Turning it on sends no email: they request a sign-in link when they want one. Turning it off signs them out at once.

There’s no middle option yet. Embedding the hosted portal inside your app without a separate login isn’t built. Today it’s your own page, the hosted portal, or both.

What you give up, and what you get

Against a widget, headless costs you the UI work, and the production work around it: your own auth, storing partner ids, a queue that retries event reports, a shared stats cache. The sample covers the web page and shows where each of those goes. Also missing today: scoped reads for codes and the funnel, per-key rate limits, and SSO into the hosted portal. They’re on the guide’s not-built-yet list.

What you get:

  • A page that looks like your product, on web and mobile, inside your own auth.
  • One ledger for every kind of partner. Customer referrers, affiliates and creators share the same attribution, commission rules, refund handling and payout runs.
  • Attribution beyond the cookie. First-party click capture, login stitching, and code redemptions as a second way in.
  • Your data. Export every table as CSV, JSON or SQL whenever you like.

On the hosted plans, the fee is a share of referred revenue: 3% of each referred sale on Revshare, with no monthly fee, or $49 a month plus 1.5% on Flex. If a commission is worth more than 30% of the sale, the fee is 10% of the commission instead (5% on Flex). That matters for flat rewards on small sales: a $25 reward on a $49 first payment costs $2.50 on Revshare. A bounty with no sale amount costs 10% of the bounty (5% on Flex). The fee is billed to you and never comes out of what your customers earn. The pricing page has a calculator.

To try it, run the sample against your OpenPartner account: node setup.mjs once with an admin key, then node server.mjs. Its program pays 20% of every payment for 12 months and has no friend discount, and its page shows the link but not the code. To match this post, change the commissionRule in setup.mjs, add a customerReward, and read each partner’s code with a separate admin-key job as described in step 2.