Skip to content

Outbound conversion export

Owner: media buying.

Stitch sends conversions back to the ad platforms so they bid on funded loans rather than form fills. This page is what to configure and why it is shaped the way it is.

Every destination ships disabled. Enabling one is a row and an access token.


A mortgage funds about 45 days after the click. That matters more than it sounds:

PlatformAttribution windowA conversion 45 days later
Google Ads90 days, matched on gclidFine. Offline conversion import is built for this
Meta7-day click / 1-day viewOutside the window. Cannot attribute it, cannot learn from it
TikTok7-day clickOutside the window
LinkedIn30 daysMarginal

Sending only the funded loan teaches Google Ads to optimise for funded loans and teaches Meta nothing at all — its bidder never sees a single conversion.

So Stitch sends three, each a distinct event so nothing double-counts:

KindFiresCarriesMetaTikTok
leadseconds after lead creationthe brand’s configured lead valueLeadSubmitForm
applicationseconds after the loan reaches app_submitted (or the first later stage its events report)Stitch’s own predicted value for the application, from the value model (plan 32)InitiateCheckoutInitiateCheckout
fundedon fundingrealized gross revenuePurchaseCompletePayment

The first two arrive inside every platform’s window and carry a predicted value; the third is the settlement.

Google Ads and LinkedIn have no event-name concept — each kind needs its own conversion action configured in the account:

conversion_action_lead customers/123/conversionActions/…
conversion_action_application customers/123/conversionActions/…
conversion_action_funded customers/123/conversionActions/…

Pointing all three at one action would make a lead indistinguishable from a funded loan in Google’s own reporting and let the bidder optimise toward the wrong one. A single legacy conversion_action key still works as a fallback, but it is not what you want.

Which action bids. Make the application action the primary (bidding) action and the funded action an observation (secondary) action in Google Ads. The application action carries the value model’s prediction the moment the application completes, which is what value-based bidding needs, and its value is then restated to the realised revenue at funding, so leaving both primary would count that revenue twice.

The application conversion is adjusted, under its own order id (stitch-app-{loanId}), whenever the loan’s value is known better:

WhenRestated to
a re-score after the application was sent (typically the pulled FICO arriving on a later stage event)the new predicted value, when it differs from the last one sent
fundedrealised gross revenue
withdrawn, denied, deadthe floor, $1

Each restatement is one uploadConversionAdjustments row, RESTATEMENT, logged as stitch-app-{loanId}:restate:{stage}:{time} so each re-score is its own row and a retry keeps the event’s time. Each carrier (Google, the webhook) is judged on its own sends: nothing is restated to a destination the application never reached, nothing equal to the value it was last sent, and nothing dated before the last restatement it took — Google keeps the adjustment with the latest time, and a retried older one would roll it back. Google rejects an adjustment to a conversion under 24 hours old, so a restatement is written at once but held for the five-minute sweep until the application is a day old. A rejection Google reports inside a 200 (partialFailureError, for instance TOO_RECENT_CONVERSION_ACTION in the four to six hours after a conversion action is created) is recorded as a failed row and retried by the sweep. Meta, TikTok, LinkedIn, Microsoft, Taboola and Outbrain have no equivalent and decline the kind; the webhook carries it.

A lead has no loan yet, so it carries a configured expectation per brand:

INSERT INTO lead_values (brand_id, value_cents, note, updated_at)
VALUES ('bll', 24000, 'funded rate x average revenue, reviewed 2026-09', ...);

A brand with no row sends no lead conversions at all. That is deliberate. A lead reported as worth zero teaches the platform that leads are worthless, which is worse than silence — and a number nobody chose is worse than no number.

Set it from your own funded rate times average revenue, and revisit it. There is no derived version yet; when there is, it will be per channel rather than per brand, because the two differ by more than the brand does.

Switching the application event on (cutover)

Section titled “Switching the application event on (cutover)”

Before plan 32 the application event fired at app_started; it now fires at app_submitted or the first later stage. A loan that already sent one under the old rule would send again under the same event_id; the platforms deduplicate on it inside their windows (about 48 hours at Meta and TikTok), and nothing has been enabled on dev. When a destination is first enabled for real, enable it after the last app_started-era loan has left the window, or accept one duplicate per in-flight loan on day one.

Brand search, by default. It defends rather than acquires, and feeding it funded value teaches the platform to bid harder on people who were already coming to you. Set include_brand_search = 1 on a destination to override, deliberately.

A kind the platform cannot carry (kind not sent in the fan-out log): a restatement to anything but Google or the webhook.

A Taboola or Outbrain send with no click id (no_click_id): those platforms attribute on the click id alone, so there is nothing to send.

Two paths, on purpose.

The queue carries the conversion within seconds of the event — that is what makes the lead and application events land inside the attribution windows at all.

The five-minute cron sweeps export_log afterwards for anything pending or retryable (up to five attempts, a row that rebuilds to nothing counting as one), each row rebuilt as its own kind, and queues any attributed funded loan of the last 90 days the queue missed, newest first. It is the net beneath the queue, not the primary path. Both write the same (event_id, destination) row, so a conversion cannot be delivered twice. (From migration 0010 until plan 34 the sweep read a column the table no longer had and did neither.)

event_id is deterministic and shared with the browser pixel, so the platform deduplicates rather than double-counting:

stitch-lead-{leadId} stitch-app-{loanId} stitch-{loanId}

A lead whose form carried the SDK’s submission id uses that id instead of stitch-lead-{leadId}, because that is the id the page’s pixel fired with (web-sdk.md §3a).

Click IDs — gclid, gbraid, wbraid, msclkid, fbclid, ttclid, li_fat_id, twclid, epik, tblci, dicbo. For a lead or an application each destination takes its own from the person’s visits in the 90 days before it, whatever brought those visits: the newest visit carrying one of the destination’s keys, a brand-search visit only when no other does, and the brand exclusion is judged on that visit. So a person who clicked a Google ad a month ago and came back by typing the address still sends the gclid; a brand-search return in between neither replaces it nor suppresses the send; a Meta retargeting click is what Meta matches on; and a lead acquired organically whose only Google click was a brand search is not sent to Google at all, where an email-only send would be credited to that brand click. Google is sent exactly one of gclid, gbraid and wbraid — it rejects a row naming two. Nothing older than 90 days is sent: an expired click id fails the whole row where a hashed email alone would match. The funded conversion reads the credited touch. Deterministic, and the strongest signal.

Vendor pixel cookies — _fbp, _fbc, _ttp, _gcl_aw, read by the SDK when the pixels are on the page and advertising consent is not refused, from any visit in the window (a cookie identifies the browser, not an acquisition); today only Meta’s two are sent, as fbp and fbc, and they are what Meta matches on when the click id is gone.

Hashed email. Stitch never retains plaintext PII, so it sends a SHA-256 of the normalised address: Google’s normalisation to Google and Microsoft, lowercase+trim (email_sha256_plain) to the others. A person identified before that second hash existed gets Google’s, which matches every address except a Gmail one containing a dot or a +tag.

Not hashed phone, to Meta. Meta wants digits without the leading plus and Stitch stores E.164 with it, so the stored hash could never match. Sending it would be noise.

Every destination is a row in export_destinations, shipped disabled; its credentials column is a JSON object with the keys below. Write the whole UPDATE export_destinations SET credentials = '<json>' WHERE name = '…' into a file and run it with wrangler d1 execute --file, never pasted into a terminal that keeps history. A row the platform rejects inside a 200 (Google’s partialFailureError, Microsoft’s PartialErrors) is recorded failed with the platform’s reason, not sent.

DestinationDeliveryCredentialsEvent names
google_adsbearer POST with developer-token (and login-customer-id for a manager’s token), offline click conversions and adjustments, API v26client_id, client_secret, refresh_token, developer_token, customer_id, login_customer_id (optional)conversion_action_{lead,application,funded} (resource names)
meta_capibearer POST, Conversions APIaccess_token, pixel_idfixed: Lead, InitiateCheckout, Purchase
tiktok_eventsbearer POST, Events APIaccess_token, pixel_codefixed: SubmitForm, InitiateCheckout, CompletePayment
linkedin_conversionsbearer POST, Conversions APIaccess_token, conversion_rule_{kind}per rule
microsoft_adsbearer POST plus DeveloperToken, CustomerId, CustomerAccountId; offline conversions, Campaign Management v13client_id, client_secret, refresh_token, developer_token, customer_id, account_idconversion_goal_{lead,application,funded} (goal names, must exist; wait two hours after creating one)
taboolaGET, server-to-server action handler; needs tblci—event_name_{lead,application,funded}, as defined in Realize, case-sensitive
outbrainGET, unified pixel; needs dicbo (or obclid)—event_name_{lead,application,funded}
webhookPOST, the conversion as JSON, X-Stitch-Signature: sha256=<HMAC-SHA256 hex of the body> and X-Stitch-Event-Id; every kindurl, secret, include_identifiers ("true" to include hashes, click ids, cookies and visits; off by default — the receiver is whoever holds the URL)passes kind

Tokens. Google’s and Microsoft’s access tokens live an hour, so their rows hold the long-lived credential — client_id, client_secret, refresh_token — and Stitch exchanges it before a send, writes the token back as access_token with access_token_expires_at, reuses it until five minutes before expiry, and exchanges once more if the platform answers 401. Microsoft rotates the refresh token on every exchange; the new one is stored. A row with a pasted access_token and no refresh credential works until the token expires, then fails its rows with incomplete credentials; a refused exchange fails them with auth: <status> <reason>, retried by the sweep. Meta, TikTok and LinkedIn keep their long-lived access_token.

An unconfigured Taboola or Outbrain event name is sent as the kind itself (lead), so the mistake is visible at the platform rather than as an empty name. A webhook with no secret fails its rows with webhook: no secret configured rather than posting unsigned.

The coverage screen carries export health: sent, failed, pending, and last success per destination. Watch last success. An export that stopped silently three weeks ago is worse than one that never ran, because bids are being set on stale data with full confidence.

Every send is recorded as the platform saw it. export_log.request holds the method, the URL and the body; export_log.response holds the first 500 characters of what came back, sent or refused. The request never holds a header — that is where tokens travel — and a query parameter whose name says it carries a secret (token, key, secret, signature, auth) is blanked before the URL is stored, as is any user:pass@; the webhook’s URL, which is itself the credential, is stored as its origin only. Any hashed identifier in the body (a 64-character sha256) is replaced by sha256:…, so the record shows that one was sent, not which. The body is kept to 4,000 characters and marked truncated when cut.

Two places show it. The Data quality screen’s What did we send, and what came back? lists the last seven days’ sends, newest first, filterable by what happened to them; each opens to the request and the response, and its subject opens the person. The Person screen’s Sent to ad platforms lists that person’s sends across every brand. GET /api/deliveries?days=7&state=failed &destination=meta_capi is the same list for a script (days up to 90, 100 rows, state one of sent, failed, pending, withheld, shadow).

A destination with enabled = 1, shadow = 1 is fanned out to like a live one. The processor builds the request from the credentials as stored, exchanges no token, calls nothing, and writes a shadow row carrying the request and the signals. Read those rows for a day — the right conversion action, the right click id, a value that looks like money — then set shadow = 0.

Shadow rows are terminal. The sweep never retries them, they never count as sent (so no restatement is due against them), and turning shadow off does not resend them: a conversion previewed in shadow was never sent, by design. Turn shadow off early rather than late. The other direction is safe too: a row already sent stays sent if the destination is put into shadow, and a row that failed live is left alone while shadow is on, for the sweep to retry once the destination is live again. A shadow request that cannot be built (a webhook with no secret yet) fails the row with the reason, like any other failure.

Once a night, after the self-tests, the processor asks four questions and emails the admins when any answers yes — nothing on a quiet night:

AlertFires when, over the last 24 hours
destination_failinga live destination had 3 or more failed sends, or at least one failed and none sent
auth_failinga send failed on the credential (auth: … or incomplete credentials)
self_test_failingan enabled destination’s nightly endpoint check did not pass (no window)
stuck_pendingrows first tried more than a day ago are still pending and not held on purpose

The email carries no link; it names the screen. Recipients are the admins who can sign in (auth_user.role = 'admin', not banned). Shadow destinations never alert.