ViralPayouts API
Run a two-tier partner program, or your own partner account, from code. The REST API at https://viralpayouts.com/api/v1 has 66 endpoints: 45 for brands and 21 for partners. The same keys work with the MCP server for AI agents.
Quickstart
Create a key, then call the API with it as a bearer token. Every answer is JSON. Money is in minor units (cents) with a currency.
curl https://viralpayouts.com/api/v1/programs \
-H "Authorization: Bearer vpk_..."{
"data": [ { "id": "prg_9f2c1a", "name": "Acme Pro", "status": "live", "commission": "25% of each sale", ... } ],
"has_more": false,
"next_cursor": null
}Every endpoint, with its fields and an example answer, is in the API reference. The machine-readable contract is the OpenAPI 3.1 document at /api/v1/openapi.json: import it into Postman, an SDK generator or an agent.
Authentication and keys
Send a key in the Authorization header: Authorization: Bearer <key>. A key is shown once, when it is created. ViralPayouts keeps only a SHA-256 of it and its last four characters. A revoked key stops working on the next request.
| Key | Who creates it, and where | What it can do |
|---|---|---|
vpk_... | A brand owner, in the brand console: Integration tab, API keys. | Acts in one brand with one role, exactly like a team member with that role: viewer reads everything; manager also reviews partners and conversions, sends messages and test sales; owner also creates and changes programs, sets rates and webhook secrets, records refunds and reads the signing secret. |
vpp_... | A partner, in the partner portal: Your API keys. Only from a session opened by a one-time sign-in link sent to their email address. | Acts for one person across every program they are in. read sees programs, links, stats, conversions, payouts, earnings, recruits and the marketplace, and builds links. write also joins programs, sets postback URLs and sends messages. No key can connect or change a payout account, save a tax form or delete the account. |
A brand key on a partner endpoint answers 403 partner_key_required, and a partner key on a brand endpoint 403 brand_key_required. A record of another brand answers 404, exactly like one that does not exist.
Errors
Every error has the same shape: the HTTP status, a stable code, a message saying what happened, and next, the next step (which field to fix, which endpoint to call, which key to use). A server error also carries a requestId to quote to us.
HTTP/1.1 403 Forbidden
{
"error": {
"status": 403,
"code": "role_too_low",
"message": "This key has the viewer role, and POST /api/v1/programs needs an owner key.",
"next": "Ask a brand owner to create a key with the owner role in the console (Integration tab, API keys), then call again with it."
}
}| Status | Codes | When |
|---|---|---|
| 400 | invalid_request invalid_json invalid_cursor idempotency_key_required invalid_idempotency_key | A field is missing, of the wrong type, out of range or unknown. The message names it. |
| 401 | missing_key invalid_key | No key, or a key that is mistyped, revoked, or from another network. |
| 403 | role_too_low scope_too_low brand_key_required partner_key_required | The key cannot use this endpoint. |
| 404 | no_such_endpoint program_not_found partner_not_found conversion_not_found payout_not_found batch_not_found program_not_joined program_not_listed | No such endpoint, or no such record for this key. |
| 405 | method_not_allowed | The path exists but not with this method. The Allow header lists the methods. |
| 409 | conflict idempotency_in_progress commission_payable | The change does not apply to the record as it is now (nothing was changed), or an idempotent request is still running. |
| 413 | too_large | The body is over 100 KB. |
| 422 | idempotency_key_reused | An Idempotency-Key was reused for a different request. |
| 429 | rate_limited | Too many requests. Wait for Retry-After seconds. |
| 500 | server_error | Our fault. Nothing was changed. Retry; quote the requestId if it persists. |
Pagination
Lists are newest first and use cursors. Send limit (1 to 100, default 25). The answer has data, has_more and next_cursor. To get the next page, send cursor=<next_cursor> with the same filters. A cursor marks the last row you saw, so new rows arriving between pages never make you see a row twice or miss one.
curl "https://viralpayouts.com/api/v1/programs/prg_9f2c1a/conversions?status=pending&limit=50&cursor=MjAyNi0xMC..." \
-H "Authorization: Bearer vpk_..."Rate limits
Each key may make 120 requests a minute. Every answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window ends). Past the limit the answer is 429 with Retry-After. Requests with keys that are not valid are limited too: 30 a minute from one address. The MCP endpoint has its own limit of 120 requests a minute per key.
Idempotency
Send an Idempotency-Key header (1 to 255 visible characters, a UUID is ideal) on a POST, PUT or PATCH to make a retry safe. The first answer is kept for 24 hours. The same key with the same request gets that answer again, with Idempotent-Replayed: true, and nothing runs twice. The same key with a different request gets 422 idempotency_key_reused. A retry while the first request is still running gets 409 idempotency_in_progress. A server error (5xx) is not kept, so you can retry it with the same key.
Recording a refund moves money, so POST /conversions/{conversionId}/refund requires the header. Over MCP the same value is the idempotencyKey argument.
curl -X POST https://viralpayouts.com/api/v1/conversions/cnv_0c88aa/refund \
-H "Authorization: Bearer vpk_..." \
-H "Idempotency-Key: 6f1c2f0e-3b7a-4c1e-9a55-2d0f7f1b8e21" \
-H "Content-Type: application/json" \
-d '{"amountCents": 2500}'Event API: send sales from your server
Your backend reports each sale, refund and app event to POST https://viralpayouts.com/api/v1/events, signed with the program's signing secret (an owner key reads it from GET /programs/{programId}/integration, or copy it from the console's Integration tab). This endpoint uses the signature, not an API key.
| Header | Value |
|---|---|
x-vp-program | The program id (or slug). |
x-vp-timestamp | The time you signed, in unix seconds. It must be within 5 minutes of our clock. |
x-vp-signature | Hex HMAC-SHA256 of <timestamp>.<raw body> with the signing secret. After you rotate the secret, the previous one keeps working for its grace period; the answer says which one matched (signedWith). |
import crypto from 'node:crypto';
const raw = JSON.stringify({ id: 'evt_1', type: 'sale', order_id: 'ord_1', amount_cents: 10000, currency: 'USD', click_id: clickId });
const ts = String(Math.floor(Date.now() / 1000));
const sig = crypto.createHmac('sha256', process.env.VP_SIGNING_SECRET).update(`${ts}.${raw}`).digest('hex');
await fetch('https://viralpayouts.com/api/v1/events', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-vp-program': 'prg_9f2c1a', 'x-vp-timestamp': ts, 'x-vp-signature': sig },
body: raw,
});| Field | On | What it is |
|---|---|---|
id | every event | Required. Your unique id for this event, up to 200 characters. Sending the same id again is recorded once (the answer says duplicate), so retries are safe. |
type | every event | Required. sale, refund or event (an app event such as a signup). |
order_id | sale, refund | Required. Your order id, up to 200 characters. A refund names the order it refunds. |
amount_cents | sale, refund | A sale: the amount in cents, a positive whole number. A refund: the amount of this refund, in cents. |
refunded_total_cents | refund | Instead of amount_cents: the total refunded on the order so far. |
currency | sale | Three-letter code, such as USD. |
click_id | sale, event | The vp_click value from the landing URL. The surest way to credit the right partner. |
ref_code | sale, event | The ref value from the landing URL (a partner handle or link code), when there is no click id. |
customer_email | sale | Used to check for self-referral, then stored only as a keyed hash and a masked form. |
occurred_at | sale | ISO 8601 time of the sale. A time in the future is recorded as now. |
sub_id (or sub1), sub2 to sub5 | sale | The partner's own labels. Each falls back to the one on the click. |
coupon_code | sale | A partner's coupon code the buyer used. Credits that partner while the program's coupon offer is on. |
subscription_id | sale | Your id for the subscription. Send it on every renewal: a renewal is credited to the partner who brought the first payment, for as long as the program's renewal period lasts. |
product_id (or plan) | sale | The product or plan sold. Paid that product's rate if the program sets one, otherwise the program's commission. |
event | event | The event name, such as signup or install. Paid the program's bounty for that name. |
event_id | event | Your id for what happened (the user, the install). The same event for the same id earns once. |
The answer is { "status": "processed" | "duplicate" | "unmatched" | "error", "note", "conversionId", "signedWith" }: 200, or 422 when the status is error. unmatched means no partner could be credited (the note says why); a refund for an order not seen yet is kept and applied if the sale arrives. A body may be up to 64 KB. Each program may send 600 valid events a minute and each address may fail 20 times a minute by default; past either the answer is 429 with Retry-After.
Webhooks and postbacks
Sales coming in. Your checkout reports sales and refunds to ViralPayouts in one of three ways. GET /programs/{programId}/webhooks shows the URLs for a program and whether each secret is set; GET /programs/{programId}/events shows every event received and what happened to it.
| Source | URL | Verified by |
|---|---|---|
| Your backend | POST https://viralpayouts.com/api/v1/events | The signing secret, as above (Event API). |
| Stripe | https://viralpayouts.com/api/stripe/webhook/{programId} | The Stripe endpoint's whsec_ secret, set with PUT /programs/{programId}/webhooks/stripe. Write-only. |
| Shopify | https://viralpayouts.com/api/shopify/webhook/{programId} | The Shopify webhook secret, set with PUT /programs/{programId}/webhooks/shopify. Write-only. |
Postbacks going out. A partner can give each program a postback URL (PUT /partner/programs/{program}/postback). When one of their sales is recorded, approved or reversed, ViralPayouts calls it once with a GET (5-second timeout, redirects not followed). A timeout, a 5xx, 408 or 429 is retried up to 6 times; another 4xx is not retried. Test sales never send one. Macros: {sub_id} {sub2} {sub3} {sub4} {sub5} {click_id} {conversion_id} {payout} {currency} {status} {event} {program}.
What the API covers
| Area | Brand key | Partner key |
|---|---|---|
| Programs | List, get, create, update, results, integration, funding and invoices | Programs you are in; marketplace search; join |
| Partners | List, get, approve, reject, suspend, reinstate, warn, payment schedule, stats | Your account and memberships |
| Links and clicks | Partner links, clicks, sub-ID, country and device reports | Your links, links with sub IDs, your sub-ID, country and device reports |
| Conversions | List, get, approve, reject, refund, test sales | Your sales and your recruits' sales |
| Commissions | The ledger, balances per partner | Your balance per program and across programs |
| Two-tier | Recruiter overrides earned, recruits, product rates and event bounties | Your recruits and the override you earned |
| Payouts | Payouts and payout batches (read) | Your payouts |
| Messages and webhooks | Message a partner or all, webhook secrets, incoming events | Message the brand, postback URL and test |
Starting payout batches, connecting payout accounts and tax forms stay in the console and the portal. The full list is in the reference.