You bill the customer. We pay the partner who sent them. This is how your system tells ours that something happened.
Every partner has a slug — jane-doe, ridgeline. When a partner sends a
buyer to you, we forward them to your signup URL with that slug appended:
https://yoursite.com/signup?plan=launch&ref=jane-doe
You choose the parameter name — set it in your portal and we'll use it. Two things to do with it:
sale.new.That's the whole attribution mechanism. There is no pixel and nothing to embed.
POST https://partners.ownoutright.com/merchant-hook.php
Content-Type: application/json
| Header | Value |
|---|---|
X-OO-Merchant | your API key — shown in your portal |
X-OO-Timestamp | current unix time, in seconds |
X-OO-Signature |
sha256= + HMAC-SHA256 of "{timestamp}.{raw body}", keyed with your shared secret, hex |
Sign the exact bytes you send. Build the body string first, sign that string, and send it unchanged. Re-serialising the JSON after signing produces a mismatch, and it is the one mistake that catches nearly everybody.
// PHP
$body = json_encode($event);
$ts = (string)time();
$sig = hash_hmac('sha256', $ts . '.' . $body, $secret);
// X-OO-Merchant: $key | X-OO-Timestamp: $ts | X-OO-Signature: sha256=$sig
# Python
body = json.dumps(event)
ts = str(int(time.time()))
sig = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
// Node
const body = JSON.stringify(event);
const ts = String(Math.floor(Date.now() / 1000));
const sig = crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
Requests more than 5 minutes old are rejected — send the timestamp you actually signed with, not one from a queued retry.
Every event needs a unique, stable event_id. We store it and ignore repeats, so
retrying is always safe — you can never cause a double payment by sending the same
event twice.
sale.new — a referred customer subscribed{
"event_id": "evt_01H8XK",
"type": "sale.new",
"ref": "jane-doe",
"external_id": "sub_9981", // YOUR subscription id
"amount_cents": 9900,
"customer": { "email": "dana@hvac.example", "name": "Dana Owner", "company": "Dana HVAC" },
"recurring": true, // false for a one-off purchase
"occurred_at": 1756800000
}
external_id is your subscription ID. It is how every later event finds this
deal, so it has to be stable for the life of the subscription.
Set recurring to false when the customer bought something that does not renew
— a one-off product or a single piece of work. The partner is paid on the sale and nothing more.
Leave it out and we treat the deal as a subscription, which is what it was for every merchant before
one-off sales existed.
Send this even when ref is empty or unrecognised — we record the sale as unattributed
rather than rejecting it, and you get a 200 either way.
A ref only pays when that partner has been approved for your offer. Partners apply to
you in their hub and you approve them on the Partners tab of your dashboard — or turn
on automatic approval there and stop thinking about it. A sale carrying the slug of someone you have not
approved is recorded, and pays nobody.
payment.succeeded — a renewal cleared{
"event_id": "evt_01H9AA",
"type": "payment.succeeded",
"external_id": "sub_9981",
"amount_cents": 9900,
"period": "2026-10", // the month being paid for
"occurred_at": 1759478400
}
One per month per subscription. If you omit period we derive it from
occurred_at. You may send this for the first month too — we already counted it against
sale.new and will skip it, which is simpler for you than special-casing month one.
subscription.cancelled — they stopped{ "event_id": "evt_01HBB", "type": "subscription.cancelled",
"external_id": "sub_9981", "occurred_at": 1767225600 }
Recurring commission stops. Nothing already paid is touched.
Please actually send this one. If cancellations go unreported we keep paying a residual on a customer you no longer have, and the first anyone notices is an awkward reconciliation months later.
sale.refunded — the sale was reversed{ "event_id": "evt_01HCC", "type": "sale.refunded",
"external_id": "sub_9981", "occurred_at": 1757000000 }
Commissions not yet paid out are cancelled. Anything already paid to a partner is left alone and flagged for a human — we don't claw money back out of someone's bank automatically.
| Status | Meaning | Retry? |
|---|---|---|
200 | Processed. | No |
202 | Accepted but not acted on — usually an external_id we've
never seen. Recorded for review. | No |
400 | Malformed. The body says what's wrong. | No — fix it |
401 | Bad key, bad signature, or stale timestamp. | No — check the secret and your clock |
500 | Our side failed. | Yes, with backoff |
The response body is always JSON:
{ "ok": true, "lead_id": 42, "attributed": true,
"partner": "jane-doe", "message": "Deal won. $37.13 booked…" }
Retry 500s with exponential backoff for up to 24 hours. The event_id makes that free.
Until the offer is approved you are in test mode. Everything above runs for real — signature, timestamp window, replay guard, referral lookup — and the one thing suppressed is money. No commission can be created, so you can fire as many events as you like without consequence.
Every response in test mode carries "test_mode": true.
The merchant portal shows what we received from you within seconds of you sending it, and what we made of each one. It is usually faster to read than your own logs. It also tracks five checks:
sale.new reached uspayment.succeeded reached usevent_idsubscription.cancelled reached usWhen all five are green, submit for review. We confirm the commercial terms and switch you live. Nothing goes in front of a partner until then — a partner who sells something and doesn't get paid doesn't sell a second one.
On approval your test data is deleted. That's deliberate: if a rehearsal
external_id stayed on file, the first real sale that reused it would be waved through as a
duplicate and the commission would go missing silently. Prefixing your test IDs
(test_sub_1) makes this obvious to everyone, but the purge means you don't have to rely on it.
A signed monthly export works, on the same fields — external_id, ref,
amount_cents, period, status. It's slower to reconcile and cancellations show up
late, so it's a starting point rather than the destination.
What doesn't work is "we'll email you when there's a sale". The partner has already been promised a number by then.