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.
Three events, not one
Section titled “Three events, not one”A mortgage funds about 45 days after the click. That matters more than it sounds:
| Platform | Attribution window | A conversion 45 days later |
|---|---|---|
| Google Ads | 90 days, matched on gclid | Fine. Offline conversion import is built for this |
| Meta | 7-day click / 1-day view | Outside the window. Cannot attribute it, cannot learn from it |
| TikTok | 7-day click | Outside the window |
| 30 days | Marginal |
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:
| Kind | Fires | Carries | Meta | TikTok |
|---|---|---|---|---|
lead | seconds after lead creation | the brand’s configured lead value | Lead | SubmitForm |
application | seconds 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) | InitiateCheckout | InitiateCheckout |
funded | on funding | realized gross revenue | Purchase | CompletePayment |
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.
Restatement (Google only)
Section titled “Restatement (Google only)”The application conversion is adjusted, under its own order id
(stitch-app-{loanId}), whenever the loan’s value is known better:
| When | Restated 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 |
funded | realised gross revenue |
withdrawn, denied, dead | the 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.
Lead value
Section titled “Lead value”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.
What gets excluded
Section titled “What gets excluded”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.
How it is delivered
Section titled “How it is delivered”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).
What Stitch can match on
Section titled “What Stitch can match on”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.
Destinations
Section titled “Destinations”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.
| Destination | Delivery | Credentials | Event names |
|---|---|---|---|
google_ads | bearer POST with developer-token (and login-customer-id for a manager’s token), offline click conversions and adjustments, API v26 | client_id, client_secret, refresh_token, developer_token, customer_id, login_customer_id (optional) | conversion_action_{lead,application,funded} (resource names) |
meta_capi | bearer POST, Conversions API | access_token, pixel_id | fixed: Lead, InitiateCheckout, Purchase |
tiktok_events | bearer POST, Events API | access_token, pixel_code | fixed: SubmitForm, InitiateCheckout, CompletePayment |
linkedin_conversions | bearer POST, Conversions API | access_token, conversion_rule_{kind} | per rule |
microsoft_ads | bearer POST plus DeveloperToken, CustomerId, CustomerAccountId; offline conversions, Campaign Management v13 | client_id, client_secret, refresh_token, developer_token, customer_id, account_id | conversion_goal_{lead,application,funded} (goal names, must exist; wait two hours after creating one) |
taboola | GET, server-to-server action handler; needs tblci | — | event_name_{lead,application,funded}, as defined in Realize, case-sensitive |
outbrain | GET, unified pixel; needs dicbo (or obclid) | — | event_name_{lead,application,funded} |
webhook | POST, the conversion as JSON, X-Stitch-Signature: sha256=<HMAC-SHA256 hex of the body> and X-Stitch-Event-Id; every kind | url, 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.
Health
Section titled “Health”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.
The delivery record (plan 38)
Section titled “The delivery record (plan 38)”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).
Shadow mode (plan 38)
Section titled “Shadow mode (plan 38)”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.
Alerts (plan 38)
Section titled “Alerts (plan 38)”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:
| Alert | Fires when, over the last 24 hours |
|---|---|
destination_failing | a live destination had 3 or more failed sends, or at least one failed and none sent |
auth_failing | a send failed on the credential (auth: … or incomplete credentials) |
self_test_failing | an enabled destination’s nightly endpoint check did not pass (no window) |
stuck_pending | rows 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.