Audience: an implementing agent (or human) wiring up a server-side caller that needs to report attribution data to Spruce's backend. The first concrete use case is l.sprucehealth.com (the existing pre-blessed link shortener), but the spec is caller-agnostic — any backend service that handles a user's HTTP request and wants to record attribution can use it.
This spec covers the outbound call to associateAttribution only. The caller decides when to fire it (on a redirect, a page load, a click handler), what values to include, and how to handle its own user-facing response. This spec describes the contract on the wire.
It records a row of attribution data keyed by a per-device identifier. Each row is a bag of {key, value} pairs (UTM params, referrer, partner promo codes, custom keys, etc.) plus an origin (hostname) and originDetails (path). Rows feed the Looker attributions model used to measure campaign performance.
The mutation is unauthenticated. The only identity signal is a per-device cookie called did. The backend handles cookie minting itself (see "Device-ID handling" below), so the caller just needs to relay cookies in both directions.
POST https://msg-api.sprucehealth.com/graphql
Content-Type: application/json
There's only one environment for this caller: production. (Spruce has dev/staging variants of msg-api, but they aren't relevant to a server-side caller running in production against the production attribution system.)
This is a server-to-server call. CORS does not apply — CORS is a browser-side enforcement mechanism, and your handler is a server. Don't set, read, or worry about Origin, Access-Control-Allow-*, preflight OPTIONS, or credentials headers.
The Spruce attribution system is keyed on a per-device cookie called did. The backend already knows how to mint and rotate this cookie. Your caller's job is just to be a faithful proxy for the cookie in both directions.
1. On the inbound user request, capture the Cookie header (everything, not just `did`).
2. When making the outbound POST to msg-api, attach those cookies as the
Cookie request header.
3. On msg-api's response, capture every Set-Cookie response header.
4. On YOUR response back to the user, attach those Set-Cookie headers
verbatim.
That's it. The backend (sprucehealth/backend/device/headers.go's ExtractSpruceHeaders) covers all three cases:
- User has a
didcookie: backend reads it from the forwarded Cookie header, noSet-Cookieis sent back. Your relay is a no-op on the response side. - User has no
didcookie (first click): backend mints an opaque ~22-character token (16 random bytes, URL-safe base64-encoded) and emitsSet-Cookie: did=<token>; Domain=sprucehealth.com; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=315360000. Your caller forwards that header verbatim to the user, who is now bound to a device ID for ~10 years across all*.sprucehealth.comproperties. - User has an
S-Device-IDheader (e.g. native apps): backend reads it directly. Doesn't apply to web callers likel.sprucehealth.com, but mentioned here for completeness.
Don't mint device IDs in your caller code. Don't Set-Cookie did from your caller. Don't transform or filter the backend's Set-Cookie headers — just relay them. Doing your own minting fragments device IDs across services and breaks attribution stitching.
If your runtime's HTTP-client library or web framework strips Set-Cookie from responses by default (some do), make sure you've explicitly opted out of that.
Forward all inbound cookies from the user's request to msg-api as a Cookie header on the outbound request. Don't filter to just did. Other cookies (e.g. _fbc, _fbp for Meta Ads attribution) are needed for future analytics paths the backend may add.
Content-Type: application/json(required)User-Agent: <your-service-name>/<version>— set explicitly so attribution rows are recognizable (e.g.l-sprucehealth-com/1.0). The backend records this on the row.
{
"operationName": "associateAttribution",
"query": "mutation AssociateAttribution($input: AssociateAttributionInput!) { associateAttribution(input: $input) { success errorCode errorMessage } }",
"variables": {
"input": {
"values": [
{"key": "url", "value": "<origin + pathname of the URL the user hit, no query string>"},
{"key": "utm_source", "value": "<...>"},
{"key": "utm_medium", "value": "<...>"},
{"key": "utm_campaign", "value": "<...>"}
],
"origin": "<hostname of the URL the user hit>",
"originDetails": "<pathname of the URL the user hit>"
}
}
}The selection set is a deliberate subset of what marketing-website's attribution.js selects today (which also pulls attributionSuccessModal { ... }). Server-side callers don't render that UI, so omit it.
- One entry per inbound query parameter the user sent. Include all of them; the backend filters and remaps as needed.
- Include a synthetic
urlentry:<origin><pathname>of the URL the user actually hit on your service, with no query string. This mirrors what the browser-side code sends and is the convention the backend's analytics pipeline expects. - If your caller has additional context worth recording (e.g. for
l.sprucehealth.com, the pre-blessed-link's resolved destination URL), add it as another{key, value}entry. Pick a stable key name and document it in your caller's code. - If a key appears multiple times in the query string, include each occurrence as a separate
valuesentry. Don't dedupe, don't comma-join. - Keys and values are forwarded as the user sent them, decoded:
?utm_source=email%20blastbecomes{"key": "utm_source", "value": "email blast"}. - Don't filter or rename keys. The backend handles:
- Synonym mapping for browser privacy modes that strip
utm_*:uca → utm_campaign,ume → utm_medium,uso → utm_source,uco → utm_content,ute → utm_term. - OAuth-leakage guards:
stateandcodeare stripped server-side. fbclid → fbcauto-creation: if you sendfbclidand notfbc, the backend generatesfbc.
- Synonym mapping for browser privacy modes that strip
originis the hostname of the URL the user hit on your service (e.g.l.sprucehealth.com).originDetailsis the pathname of the same URL (e.g./x123).- Don't include the query string in either field; the query params are already in
values. - If you leave either field empty, the backend falls back to header-derived defaults (
Platform,DeviceType). Don't rely on this; set them explicitly.
The resolver automatically appends platform, os, ip_address, user_agent, organization_id from request context and headers before persisting. As long as your outbound HTTP client sends a sensible User-Agent and the user's IP is forwarded (CloudFront does this via X-Forwarded-For), those fields populate themselves. You don't need to put them in values.
- Timeout the outbound call at ~5 seconds. A hung
msg-apishould not block the user's response. - Don't fail the user's request because attribution failed. Log the error, swallow it, and continue. The user gets their redirect / page / response either way.
- Network error, non-2xx HTTP, or
data.associateAttribution.success === falseall count as failure. Log all three with enough detail to debug (status code, error body excerpt, theoriginDetailsyou sent — but not raw cookie values or PII).
If your runtime is a long-running server (Go, PHP-FPM, Node SSR, etc.), fire the call as a background task without awaiting it before returning the user-facing response. The user gets their redirect / page immediately; the attribution call completes ~50–200 ms later.
If you do this, make sure you've already captured the relevant inbound state (the cookies, the URL, the query params) into local variables before kicking off the background task — don't reach back into a request object that may be torn down once your handler returns. Acceptable risk for either timing mode: if the process is killed in the small in-flight window, that one call's row is lost.
If your runtime is serverless (Lambda, Cloudflare Worker without event.waitUntil, etc.) or otherwise can't keep an outgoing request alive past the response, await the call (with the 5 s timeout above) before returning. The latency cost is acceptable as a fallback.
Set-Cookie pass-through note for fire-and-forget mode: if you fire the attribution call after returning the user's response, you can't relay the response's Set-Cookie to the user — they've already gotten their bytes. This is fine for repeat clicks (the user already has did) but means first-click users won't get their cookie set on that first click. Two ways to handle:
- Accept it. The user will hit a Spruce property again at some point (sprucehealth.com, app.sprucehealth.com, help.sprucehealth.com, the next l.sprucehealth.com link), and that property will set the
didcookie. The first click's attribution row gets a freshly-minted backend-side device ID that won't be linked to subsequent clicks. Some loss of stitching, no broken UX. - Await on first click only. Detect "no
didcookie inbound", await the attribution call in that case so you can capture theSet-Cookie, and fire-and-forget for repeat clicks. Adds ~200 ms to the first click only.
Document which mode you chose in the code, near the call site.
- Logging: every call logs the
originDetails, the count of values, and whether the call succeeded. Don't log raw cookie values, raw URLs (those may carry PII or campaign secrets), or the contents ofvalues. - Metrics: count of calls, success rate, p50/p99 latency. Alert on success rate dropping below ~95 % over a 15-minute window.
- No retries. If the call fails, log it and move on. Retrying creates duplicate rows; the analytics pipeline is more sensitive to that than to occasional missed rows.
A correct implementation:
- Includes one
valuesentry per inbound query parameter. - Includes a synthetic
urlvalue:<origin><pathname>of the URL the user hit, with no query string. - Produces multiple
valuesentries when the same query-string key appears more than once (no deduping or comma-joining). - Sets
originto the hostname of the URL the user hit andoriginDetailsto its pathname. - Forwards the inbound
Cookieheader verbatim on the outbound request. - Forwards every
Set-Cookieheader frommsg-api's response verbatim on the response back to the user (when its own response hasn't already been sent). - Does not fail or surface an error to the user when the attribution call fails (timeout, network error, non-2xx, or
data.associateAttribution.success === false). - Logs every failure with enough context to debug (status code, the
originDetails, an error excerpt — never raw cookie values, full URLs, orvaluescontents).
- The user-facing behavior of the calling service (e.g. how
l.sprucehealth.comresolves a pre-blessed link to a destination, or what response shape it returns). That belongs in the caller's own spec. - Authenticated-user attribution. The mutation is public;
didis the only identity signal at this layer. - Rate limiting on the caller side. Spruce's edge handles abuse. If your caller is high-volume, add your own protections.
- Idempotency. Do not retry. The mutation is not designed to be idempotent and retried calls would create duplicate rows.
- Browser-side reference implementation:
marketing-website/automatically-uploaded-via-github/spruce-js/footer/attribution.js - Backend resolver:
backend/cmd/svc/baymaxgraphql/internal/resolvers/mutation_invite.go(AssociateAttributionfunction) - Backend cookie-minting middleware:
backend/device/headers.go(ExtractSpruceHeaders) - Slack thread that kicked this off: https://spruce.slack.com/archives/CHFQSKGVA/p1777363407457499
- Asana task: https://app.asana.com/1/7423375154038/project/1201497668075595/task/1214416651266469