Aller au contenu principal

Sending events to your source

Traffic sources pay for results, and to count a result a source has to hear about it. Every source wants that news in its own shape: one expects &status=site_registration_isok, another &status_app=approve, a third wants a deposit reported with its amount and currency.

Qubix keeps every event it sees in one place, and a script can read that place and call any address you allow. So the report goes out in exactly the format your partner asks for — you choose the events, you build the query, you decide what each parameter carries.

This article builds one working script and explains every part of it, so you can bend it to your own partner.

Step 1. Allow the address

Outbound requests are closed until an administrator opens them, and the list is empty on a fresh box — a script calling an address nobody allowed gets a refusal in its console.

  1. Open Système → the JavaScript tab.
  2. Add the host of your partner — the bare host name, without https:// and without a path: partner.example.com.
  3. Save.
Attention

The host is matched exactly: partner.example.com does not cover www.partner.example.com. Add every host you will actually call.

Step 2. Look at what your own events carry

All events live in one table, qubix_events: visits from ads, page renders, PWA installs, push deliveries and clicks, and the conversions your affiliate networks post back to you. What each event carries depends on which event it is — a visit brings the landing address with every marker your source put in it, while a registration or a deposit brings a status, an amount and a currency.

Before writing anything, look at your own data. Open any script and run this:

JavaScript
function main() {
const rows = sql`
SELECT event, event_time, piuid, status, revenue, currency, country, url
FROM qubix_events
WHERE event_time > now() - INTERVAL 7 DAY
ORDER BY event_time DESC
LIMIT 20`
for (const r of rows) console.log(r.event, '|', r.status, r.revenue, r.currency, '|', r.url)
}

To see which kinds of events your own traffic produces, and how many of each, ask for the list itself:

JavaScript
function main() {
const kinds = sql`
SELECT event, count() AS n, max(event_time) AS last_seen
FROM qubix_events
WHERE event_time > now() - INTERVAL 30 DAY
GROUP BY event
ORDER BY n DESC`
for (const k of kinds) console.log(k.event, k.n, k.last_seen)
}

Typical names: campaign_visit (a click from an ad), render (the page was shown), white_page (the cloak served the safe page), installed and install_accepted (a PWA install — they come in pairs, so count only one of them), launch_pwa (the installed app was opened), reg and dep (a registration and a deposit, arriving from an affiliate network), and the push_* family for deliveries, displays and clicks of push notifications.

Two things are worth noticing in the output, because the script below is built around them.

A registration or a deposit does not carry your partner's marker. It knows piuid — the visitor identifier Qubix assigns. The marker your partner sent arrived earlier, on the visit that started this journey, and that visit stores its whole raw address. So the marker is read from there, and any other parameter of that link can be read the same way — including ones nobody planned for in advance.

Readable names live in their own tables. An event carries offer_id, not the offer name. The name is fetched separately, which is why the script has a getOffer() function — and why anything else you keep in Qubix is reachable exactly the same way.

Step 3. The script

Set the schedule to * * * * * — once a minute.

postback-to-partner.jsJavaScript
// ════════════════════════════════════════════════════════════════════════════
// Reporting conversions back to your traffic source.
//
// Every minute the script looks for events that happened since the previous run,
// builds the request your partner expects, and sends it. Where it stopped is
// remembered between runs, so nothing is reported twice and nothing is missed.
//
// A script is exactly one `function main()` with everything inside it — that is
// the shape the editor accepts.
// ════════════════════════════════════════════════════════════════════════════

function main() {
// ─── Everything you change for your own partner lives here ────────────────
const options = {
// 1. EVENT — which events we pick up. An empty list or a zero = no restriction.
event: {
filter: {
events: ['reg', 'dep'], // event kinds to report
countries: [], // e.g. ['GH', 'NL'] — these geos only
offers: [], // e.g. ['27074f0c-…'] — these offers only
minRevenue: 0, // e.g. 1 — skip leads that pay nothing
},
},

// 2. SEND TO — the address, and the query built for each event.
send_to: {
url: 'https://partner.example.com/postback',

// Called once per event with three sources, and you decide what goes where:
// event — the event row: event, event_time, piuid, status, revenue, currency, country, offer_id
// link — every parameter of the link this visitor arrived by
// offer — the offer card: name, payout_value, payout_currency, geo, state
// Keys become parameter names; empty values are dropped.
query: function (event, link, offer) {
return {
// The partner's own marker if it sent one; otherwise piuid — the id we
// handed the partner ourselves in the offer link.
clickid: link.sub1 || event.piuid,
status: event.event === 'dep' ? 'deposit' : 'site_registration_isok',
sum: event.revenue,
cur: event.currency,
geo: event.country,
offer: offer.name, // readable name, not an id
}
},
},

// 3. RUN — how the run itself behaves.
run: {
windowMin: 60, // how far back each run looks; the schedule may be far more often
batch: 20, // events per run — stays under the outbound-call ceiling
timeoutMs: 10000, // how long we wait for the partner
},
}

// ─── Helpers. They live inside main() because the editor allows exactly one
// top-level function and nothing else. ─────────────────────────────────

// The database wants 'YYYY-MM-DD HH:MM:SS'. ctx.now() is the server clock.
function minutesAgo(minutes) {
const t = new Date(ctx.now().getTime() - minutes * 60000)
return t.toISOString().slice(0, 19).replace('T', ' ')
}

// The events themselves, oldest first — ids and raw values only. Everything
// readable is fetched separately below. Each filter line reads "setting is empty
// OR the column matches", so an unused filter costs nothing.
//
// Each run looks at a WINDOW — the last hour — rather than walking forward from a
// saved position. Telemetry can be written a little after the moment it describes,
// and a moving position would step over such an event for good; a window sees it on
// the next run. What keeps events from being reported twice is the marks below,
// not the window.
function eventsInWindow() {
const countries = options.event.filter.countries.join(',')
const offers = options.event.filter.offers.join(',')

return sql`
SELECT event_time, event, piuid, status, revenue, currency, country, offer_id
FROM qubix_events
WHERE event IN splitByChar(',', ${options.event.filter.events.join(',')})
AND event_time > now() - INTERVAL ${options.run.windowMin} MINUTE
AND (${countries} = '' OR country IN splitByChar(',', ${countries}))
AND (${offers} = '' OR offer_id IN splitByChar(',', ${offers}))
AND revenue >= ${options.event.filter.minRevenue}
ORDER BY event_time, piuid, event
LIMIT ${options.run.batch}`
}

// Turns 'https://host/path?a=1&b=2' into {a: '1', b: '2'}.
// By hand because scripts run on an ECMAScript engine: URL and URLSearchParams
// are a browser API and do not exist here. The name is cut at the FIRST '=' —
// values legitimately contain '=' (a nested address, a base64 tail).
function parseQuery(url) {
const params = {}
const query = String(url).split('?')[1] || ''

for (const pair of query.split('&')) {
if (!pair) continue
const eq = pair.indexOf('=')
const name = eq === -1 ? pair : pair.slice(0, eq)
params[decodeURIComponent(name)] = eq === -1 ? '' : decodeURIComponent(pair.slice(eq + 1))
}
return params
}

// Every parameter of the links these visitors arrived by, in ONE query.
//
// Why in one: a run may make only a limited number of database queries (20 by
// default), and asking per visitor blows through that on a full portion. One
// query for the whole portion keeps the count fixed no matter how many events
// came in.
//
// A registration or a deposit knows only piuid; the marker your partner sent
// arrived earlier, on the visit that started the journey, and that visit stores
// its whole raw address.
function linksFor(events) {
const visitors = events.map(function (e) { return e.piuid }).join(',')
const rows = sql`
SELECT piuid, argMin(url, event_time) AS url
FROM qubix_events
WHERE event = 'campaign_visit'
AND piuid IN splitByChar(',', ${visitors})
GROUP BY piuid`

const byVisitor = {}
for (const r of rows) byVisitor[r.piuid] = parseQuery(r.url)
return byVisitor
}

// The offer cards, also in ONE query, for the same reason.
//
// Offers are stored as a ReplacingMergeTree table, which keeps several versions
// of a row until they merge, so it is read with FINAL — otherwise an edited offer
// can be read in its old shape. This is the pattern for reaching anything else
// you keep in Qubix: one query for the whole portion.
function offersFor(events) {
const ids = events
.map(function (e) { return e.offer_id })
.filter(function (id) { return id })
.join(',')
if (!ids) return {}

const rows = sql`SELECT * FROM offers FINAL WHERE offer_id IN splitByChar(',', ${ids})`
const byId = {}
for (const r of rows) byId[r.offer_id] = r
return byId
}

// Assembles the address. Empty values are skipped so the partner never receives
// '&geo=' with nothing behind it; the rest is percent-encoded, which matters for
// offer names with spaces.
function buildUrl(query) {
const parts = []
for (const name of Object.keys(query)) {
const value = query[name]
if (value === undefined || value === null || value === '') continue
parts.push(encodeURIComponent(name) + '=' + encodeURIComponent(String(value)))
}
return options.send_to.url + '?' + parts.join('&')
}

// Marks older than the window are dropped: those events fall out of every future
// selection anyway, so keeping their marks would grow the state without end.
function withinWindow(marks) {
const edge = minutesAgo(options.run.windowMin)
const kept = {}
for (const m of Object.keys(marks)) {
if (m.slice(0, 19) >= edge) kept[m] = true
}
return kept
}

// ─── The run itself ───────────────────────────────────────────────────────

const events = eventsInWindow()

if (events.length === 0) {
console.log('no events in the last', options.run.windowMin, 'minutes')
return
}

// Three queries per run, whatever the portion size: the events, their links,
// their offers.
const links = linksFor(events)
const offers = offersFor(events)

const reported = ctx.state.get('reported') || {}

for (const event of events) {
// A mark identifies one event exactly: one visitor produces both a registration
// and a deposit, and two visitors land in the same second. The timestamp goes
// first so that sorting the marks is chronological.
const mark = event.event_time + '|' + event.piuid + '|' + event.event

// Why marks exist: the window overlaps between runs on purpose, so the same
// event is seen many times. The mark is what makes it go out exactly once.
if (reported[mark]) continue

const link = links[event.piuid] || {}
const offer = offers[event.offer_id] || {}
const query = options.send_to.query(event, link, offer)

// ctx.fetch THROWS when the address is not on the allowlist, when the host does
// not answer, and on a timeout — it does not return a failed answer. Without
// this catch the whole run dies and the state below is never saved, so the work
// already done in this run is lost.
let answer
try {
answer = ctx.fetch(buildUrl(query), { method: 'GET', timeout_ms: options.run.timeoutMs })
} catch (e) {
console.log('request failed:', String(e.message || e))
break
}

if (!answer.ok) {
// The partner answered, but with an error. Stop the run: these events stay
// unmarked, so the next run — still inside the window — takes them again.
console.log('refused', event.event, event.piuid, '→', answer.status, answer.body)
break
}

reported[mark] = true
console.log('reported', event.event, offer.name || event.offer_id, '→', answer.status)
}

// Saved at the very end and outside any failing path: whatever this run managed to
// report stays reported. Marks older than the window are dropped — they can never
// be seen again, so keeping them would only grow the state forever.
ctx.state.set('reported', withinWindow(reported))
}

How it works

Everything you change lives in options, and it is split by the question each part answers:

BlockAnswers
event.filterwhich events we pick up at all — kinds, geos, offers, a payout floor. An empty list or a zero switches that filter off.
send_towhere the report goes, and — in query — what it carries.
runhow the run behaves: how far back it looks (windowMin), the portion size (batch) and how long it waits for the partner (timeoutMs).

The query is a function, not a template. It receives three sources and returns a plain object, so there is no hidden substitution to learn:

  • event — the event row: event, event_time, piuid, status, revenue, currency, country, offer_id;
  • link — every parameter of the link this visitor arrived by: link.sub1, link.utm_source, link.ad_id, whatever your source sent;
  • offer — the offer card: name, payout_value, payout_currency, payout_type, geo, state, cap.

Because it is ordinary JavaScript, a different status per event, a condition on the amount or a parameter your partner invented last week are all one line each.

The script's own memory. ctx.state survives restarts of the box. It holds reported — one mark per event already sent. You can see it in the right panel of the editor, in the État section; deleting the reported entry makes the script report everything still inside the window again.

Why a refusal loses nothing. If the partner answers with an error, the run stops and leaves those events unmarked, so the next run — still inside the window — takes them again. Nothing is lost while the partner is down.

Why a window rather than a position walking forward. Telemetry is sometimes written a little after the moment it describes, and a position that moves forward would step over such an event for good; a window sees it on the next run. The window overlaps between runs on purpose, so what makes an event go out exactly once is the mark, not the window. Each mark starts with the event time, so trimming the list keeps the freshest marks.

Why LIMIT. A run has a ceiling on outbound calls (20 by default, configurable on the same JavaScript tab). Taking a fixed portion each minute keeps the script inside that ceiling, and a backlog is worked off over several runs instead of dying on the first one.

Tip

Values passed through ${…} are sent as safe parameters — you never assemble SQL by hand, and injection is not possible.

Write it as a sequence, not with await

Scripts run without an event loop. Promise and async/await are visible in the editor, but a .then handler never runs and code placed after an await is never reached — with no error and a green run. Keep the script straightforwardly sequential, as above: sql, ctx.fetch and ctx.state are all synchronous and return their result directly.

The same script for other events

Only options changes.

PWA installs. An install carries no status or revenue, so the query is shorter:

JavaScript
event: {
filter: { events: ['installed'], countries: [], offers: [], minRevenue: 0 },
},
send_to: {
url: 'https://partner.example.com/postback',
query: function (event, link, offer) {
return {
clickid: link.sub1 || event.piuid,
status: 'install',
geo: event.country,
offer: offer.name,
}
},
},

Deposits above a threshold only. The filter does the work, the query stays as it is:

JavaScript
event: {
filter: { events: ['dep'], countries: [], offers: [], minRevenue: 10 },
},

Push clicks — returning traffic. Useful when your partner counts re-engagement:

JavaScript
event: {
filter: { events: ['push_click'], countries: [], offers: [], minRevenue: 0 },
},
send_to: {
url: 'https://partner.example.com/postback',
query: function (event, link, offer) {
return { clickid: link.sub1 || event.piuid, status: 'retention' }
},
},

Reporting at the moment it happens

The script runs on a schedule, so a report leaves within a minute of the event. When a partner needs the call at the very instant of the action, there is a second place for the code: a site handler, which runs inside the incoming request itself, with the same outbound calls and the same database access. See Site backend.

Use the schedule for everything that tolerates a minute — installs, registrations, deposits — and a handler where the answer has to be immediate.

What's next