DEV Community

Cover image for Crediting a booking to the AI agent that sent it, without cookies
Elliot Marsh
Elliot Marsh

Posted on

Crediting a booking to the AI agent that sent it, without cookies

When an AI agent recommends a food tour and the person books it, the booking platform pays a commission to whoever owns the link. We wanted that money to reach the agent, or the person or company behind it, so we opened a partner program that pays out half of what we earn. The hard part was not the payout. It was making sure each booking, reported weeks later by a partner we do not control, can be traced to the agent that sent it.

This post covers how that works at TableJourney, a food travel guide with a public MCP server. None of it depends on a cookie in the traveller's browser.

The only thing that survives: the partner's own sub-id

Every booking partner reports commissions per booking, and every one of them echoes back one free-text field you put on the link:

Partner Field
GetYourGuide cmp
Viator campaign
Musement cid
Awin (hotels, car hire) clickref
CJ (hotels) sid

That field is the whole attribution system. Anything we know at click time has to be written into it, because it is the only thing that comes back in the commission report or the network's transaction API. So each member gets a short code, and the value we write is tjr-<code>-<placement>, for example tjr-k7m2p9xq-city-hub-module. The prefix lets the ledger pick member lines out of everything else with one LIKE 'tjr-%', and the placement tells us which part of the page converted.

Agents: the link is born attributed

An agent never sees a raw partner URL. When it calls a tool such as experiences or stays with its API key, every booking link in the answer is a short signed redirect on our domain:

https://tablejourney.com/go/<payload>.<hmac>
Enter fullscreen mode Exit fullscreen mode

The payload names the key, the kind of link and the destination. The redirect checks the signature, looks up the key's campaign, writes it into that partner's sub-id field and sends a 302. The agent passes the link on unchanged and the attribution is already inside it. Nothing needs to persist on the traveller's device, which matters because the traveller may open the link in a different browser, a chat app's web view, or days later.

Getting a key is two MCP calls: partner_join with an email address, which sends a six-digit code, and partner_confirm with that code, which returns the key and the referral code. For clients that prefer OAuth there is the usual discovery (RFC 8414 metadata, RFC 7591 dynamic registration, PKCE).

People: hold the code, stamp every link

A person shares any page with ?ref=<code> on the end. On arrival, a first-party cookie set by our server (not by script, so browsers that cap script-written storage at seven days leave it alone) holds the code for 30 days, and the page rewrites every partner link's href on load.

Rewriting on load, rather than on click, is deliberate. We tried click-time tagging first and lost attribution on more than half of the links: middle-click, "open in new tab" from the context menu, long-press on a phone and "copy link address" never fire a click event. So the stamp goes into the href itself, and the click handler only re-checks it.

The links that appear later

Our first audit of this found a gap. Two kinds of partner links were not in the page when the load-time pass ran:

  • hotel links whose dates are filled in by script with the coming weekend, which replaced the stamped href with a fresh, unstamped one;
  • cards swapped in after a location lookup ("near you") and the booking bar on phones.

Clicks still carried the code, but a long-press on iOS opened the unstamped address. The fix is a MutationObserver that stamps any partner link added to the page or whose href changes:

new MutationObserver(function (ms) {
  ms.forEach(function (m) {
    var nodes = m.type === 'attributes' ? [m.target] : m.addedNodes;
    Array.prototype.forEach.call(nodes, function (n) {
      if (n.nodeType !== 1) return;
      if (n.matches('a[rel~="sponsored"]')) stamp(n);
      if (m.type === 'childList') n.querySelectorAll('a[rel~="sponsored"]').forEach(stamp);
    });
  });
}).observe(document.documentElement, {
  subtree: true, childList: true, attributes: true, attributeFilter: ['href', 'rel']
});
Enter fullscreen mode Exit fullscreen mode

stamp leaves a link alone when its sub-id already starts with the member's mark, so its own write does not loop. A check across 15 live pages afterwards found the code on all 699 partner links.

Iframes on other people's sites

Our food tours widget is an iframe other sites embed. It runs no script, by design, so the page inside the frame cannot stamp anything. A member who embeds it adds their code to the frame's address (/embed/tours/italy/rome/?ref=<code>), and each card links to a redirect on our domain instead of straight to the partner:

/go/embed?u=<partner link>
Enter fullscreen mode Exit fullscreen mode

The click is a same-origin request from the frame's document, so under the default strict-origin-when-cross-origin referrer policy the browser sends the frame's full address, query string included, as the Referer. The redirect reads the code from it, checks it belongs to an active member, writes it into the sub-id and logs the click. It only accepts the two partners that allow our links on third-party sites, so it is not an open redirect.

Reading it back

Once a day the network APIs (Awin, CJ) return transactions with the sub-id, and the partners without one are imported from their own reports, broken down by the same field. A line with a tjr- sub-id becomes a member earning at the member's share; it becomes payable only after the partner has paid us and a holding period has passed, and every payout batch is approved by a person.

The program is open to agents and people: https://tablejourney.com/partners/. The MCP server and the full tool list are at https://tablejourney.com/agents/.

Top comments (0)