EXPEDITION LOG
Which placements actually produced signups Pro
Attribution comes with a pro listing. The rest of the expedition log is free. The log already knows every place you distributed your product. Tell it when one of them produced a signup and it can tell you which ones were worth the afternoon. One HTTP request, no tracker on your site, and no personal data crosses the wire.
No snippet, no pixel, nothing on your site. Your server tells us a placement produced a signup, and never who.
What this can and cannot tell you
It reports the number your own code sends. It is not analytics: we do not see your traffic, your users, or your funnel, and we cannot infer a signup you do not tell us about. What you get is the count you already have, filed against the placement that produced it.
That is the trade. Attribution needs something on your side that knows a signup happened, because a signup happens in your database and nothing outside it can see one. What we bring is the other half: a verified, deduplicated list of everywhere you posted, so the number lands somewhere useful.
How it works
Every entry on your log carries a public reference, and the log shows you a tagged version of your own URL that carries it. Make the entry before you post, not after: name the placement, take its link, and post that instead of your plain one. When somebody arrives through it and signs up, your code sends us one request naming the reference. The entry then shows how many signups it produced.
An entry does not need a link of its own. Somewhere you posted has one, and pasting it lets us tell you whether the post is still up. An advertisement or a newsletter swap has nothing to paste, so it stays a placement with a name and a tagged link, and we never pretend to check it.
The reference is public: it travels in a link on someone else's website. Your key is not, and it is what proves a report came from you. Anyone can read the reference off a directory page; only you can report against it.
Wiring it up
Three things happen in your code: catch the parameter when somebody lands, keep it until they sign up, and send it. The examples below are Node and Go, but nothing here is framework specific.
Step 1. Get your key
Open your expedition log, find the product, and expand Report signups from your own code. Press Create a key. It is shown once and stored hashed, so copy it then. If you lose it, replace it rather than hunting for it.
While you are there, name the placements you are about to post to. Each one gets its own tagged link immediately, which is what you will be posting in step two.
Put it in your environment alongside your other secrets. It is a bearer token: anything holding it can report signups against your log, so it does not belong in client-side code or in your repository.
INDIEASCENT_KEY=ia_sk_...
Step 2. Catch ia_ref when somebody lands
Each entry on your log shows a tagged version of your own URL, ending in ?ia_ref= and that entry's reference. Make the entry before you post: name the placement, leave the link blank if there is nothing to link to yet, and the tagged link is there immediately. Post that instead of your plain URL, then paste the post's own URL back into the entry afterwards if you want us to keep an eye on whether it is still up.
Somebody arriving through the tagged link lands with the parameter on the query string, and you have one page load to keep it before it is gone.
Placements with no public page work the same way. An advertisement, a newsletter swap, a message to a community: name it, take its link, and it reports signups like any other. We simply never try to check it, because there is nothing to check.
A cookie is the simplest place. First touch wins: if they arrive again later through a different link, the original placement is the one that earned the signup.
// Anywhere that runs on a page load, before your app renders.
// Express, as middleware:
app.use((req, res, next) => {
const ref = req.query.ia_ref
if (ref && !req.cookies.ia_ref) {
res.cookie('ia_ref', String(ref), {
maxAge: 90 * 24 * 60 * 60 * 1000, // 90 days
httpOnly: true,
sameSite: 'lax',
})
}
next()
})
// Go, as middleware:
func captureRef(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ref := r.URL.Query().Get("ia_ref"); ref != "" {
if _, err := r.Cookie("ia_ref"); err != nil { // first touch wins
http.SetCookie(w, &http.Cookie{
Name: "ia_ref", Value: ref, Path: "/",
MaxAge: 90 * 24 * 60 * 60, HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
}
next.ServeHTTP(w, r)
})
}
A cookie is not the only option. If you already store an attribution source on the user row, put it there instead; anywhere that survives from landing to signup will do. What matters is that the value reaches step three.
Step 3. Send it when the signup completes
Put this where you already create the account, after the write succeeds. Send your own id for event_id, usually the new user's primary key, so a retry cannot count twice. Do not send anything else about them.
// Node, at the end of your signup handler:
async function reportSignup(ref, userId) {
if (!ref) return // they did not arrive through a tagged link
try {
await fetch('https://indieascent.com/api/v1/signal', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INDIEASCENT_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
ref,
event: 'signup',
event_id: String(userId), // your id, not their email
}),
})
} catch (err) {
// Never fail a signup over this. Log it and move on.
console.error('indieascent signal failed', err)
}
}
// in the handler, after the user exists:
await reportSignup(req.cookies.ia_ref, user.id)
// Go, same idea:
func reportSignup(ctx context.Context, ref string, userID int64) {
if ref == "" {
return
}
body, _ := json.Marshal(map[string]string{
"ref": ref, "event": "signup",
"event_id": strconv.FormatInt(userID, 10),
})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://indieascent.com/api/v1/signal", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("INDIEASCENT_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("indieascent signal: %v", err) // never fail the signup
return
}
resp.Body.Close()
}
Do this out of band if you can. A background job or a goroutine is better than blocking the response: your signup should never be slower, or fail, because of us. Everything is idempotent on event_id, so retrying later is safe.
The same three steps, in other stacks
Catch the parameter, keep it until they sign up, send it from your server. Nothing below is a library, and none of it is required: if your stack is not here, the curl in step four is the whole protocol.
Next.js, App Router
// middleware.ts: catch it on the way in, first touch wins.
import { NextResponse } from 'next/server'
export function middleware(request) {
const ref = request.nextUrl.searchParams.get('ia_ref')
const res = NextResponse.next()
if (ref && !request.cookies.get('ia_ref')) {
res.cookies.set('ia_ref', ref, {
maxAge: 90 * 24 * 60 * 60, httpOnly: true, sameSite: 'lax', path: '/',
})
}
return res
}
// In the route handler or server action that creates the account:
import { cookies } from 'next/headers'
const ref = (await cookies()).get('ia_ref')?.value
if (ref) {
await fetch('https://indieascent.com/api/v1/signal', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INDIEASCENT_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ ref, event: 'signup', event_id: user.id }),
}).catch(() => {}) // never fail a signup because we are down
}
Rails
# app/controllers/application_controller.rb
before_action :capture_ia_ref
def capture_ia_ref
return if params[:ia_ref].blank? || cookies[:ia_ref].present?
cookies[:ia_ref] = { value: params[:ia_ref], expires: 90.days.from_now,
httponly: true, same_site: :lax }
end
# Wherever the account is created:
ref = cookies[:ia_ref]
if ref.present?
IndieAscentJob.perform_later(ref, user.id) # out of band, so signup never waits
end
# app/jobs/indie_ascent_job.rb
def perform(ref, user_id)
Net::HTTP.post(
URI('https://indieascent.com/api/v1/signal'),
{ ref: ref, event: 'signup', event_id: user_id.to_s }.to_json,
'Authorization' => "Bearer #{ENV['INDIEASCENT_KEY']}",
'Content-Type' => 'application/json')
rescue StandardError => e
Rails.logger.warn("indieascent signal: #{e.message}")
end
Django
# middleware.py
class IaRefMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
ref = request.GET.get("ia_ref")
response = self.get_response(request)
if ref and not request.COOKIES.get("ia_ref"):
response.set_cookie("ia_ref", ref, max_age=90 * 24 * 60 * 60,
httponly=True, samesite="Lax")
return response
# Where the user is created (a signal, a view, or a task):
import os, requests
ref = request.COOKIES.get("ia_ref")
if ref:
try:
requests.post(
"https://indieascent.com/api/v1/signal",
json={"ref": ref, "event": "signup", "event_id": str(user.pk)},
headers={"Authorization": f"Bearer {os.environ['INDIEASCENT_KEY']}"},
timeout=3,
)
except requests.RequestException:
pass # a signup is worth more than a signal
Laravel
// app/Http/Middleware/CaptureIaRef.php
public function handle($request, Closure $next)
{
$response = $next($request);
$ref = $request->query('ia_ref');
if ($ref && !$request->cookie('ia_ref')) {
$response->withCookie(cookie('ia_ref', $ref, 60 * 24 * 90, '/', null, true, true));
}
return $response;
}
// Wherever the account is created:
$ref = request()->cookie('ia_ref');
if ($ref) {
dispatch(function () use ($ref, $user) {
Http::withToken(env('INDIEASCENT_KEY'))
->timeout(3)
->post('https://indieascent.com/api/v1/signal', [
'ref' => $ref, 'event' => 'signup', 'event_id' => (string) $user->id,
]);
})->afterResponse();
}
PHP, no framework
<?php
// At the top of every page, before any output.
if (!empty($_GET['ia_ref']) && empty($_COOKIE['ia_ref'])) {
setcookie('ia_ref', $_GET['ia_ref'], [
'expires' => time() + 90 * 24 * 60 * 60,
'path' => '/', 'httponly' => true, 'samesite' => 'Lax',
]);
}
// After the account row is written:
if (!empty($_COOKIE['ia_ref'])) {
$payload = json_encode([
'ref' => $_COOKIE['ia_ref'], 'event' => 'signup', 'event_id' => (string) $userId,
]);
$ch = curl_init('https://indieascent.com/api/v1/signal');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('INDIEASCENT_KEY'),
'Content-Type: application/json',
],
]);
curl_exec($ch); // failure is fine: the signup already happened
curl_close($ch);
}
Three things are true of every one of these. The key is a server-side secret, so none of this belongs in client-side code. The event_id is yours and only decides whether a retry counts twice, so your own user id is the easy choice, and it must not describe the person. And the call never blocks the signup: out of band where your stack makes that easy, and swallowing its own errors where it does not.
Step 4. Check it worked
Send one by hand first. Take a reference from any entry on your log and run this. A 200 with recorded: true means it landed, and the entry will show the count.
curl -X POST https://indieascent.com/api/v1/signal -H "Authorization: Bearer $INDIEASCENT_KEY" -H "Content-Type: application/json" -d '{"ref":"PASTE_A_REF","event":"signup","event_id":"manual-test-1"}'
Run it twice. The second returns recorded: false with duplicate, which is the idempotency working rather than an error. The count on the entry stays at one.
Reporting only starts from the placements you post the tagged link to. Make the entry first, take its link, then post: an entry logged after the fact carries a reference nobody arrived through, so it sits at none reported until you update the link where you posted it.
The request in full
Everything the endpoint accepts, in one place.
POST https://indieascent.com/api/v1/signal
Authorization: Bearer YOUR_KEY
Content-Type: application/json
{
"ref": "the entry reference from the tagged link",
"event": "signup",
"event_id": "any id of your own, so a retry does not count twice"
}
And what comes back:
200 OK
{"ok": true, "recorded": true}
200 OK // same event_id sent again
{"ok": true, "recorded": false, "reason": "duplicate"}
| Field | Required | What it is |
|---|---|---|
ref | Yes | The ia_ref value from the tagged link the person arrived through |
event | Optional | Only signup is accepted, and it is the default |
event_id | Optional | Your own opaque id, used only so a retry is not counted twice. Must not identify a person |
What we will not accept
No emails, no user ids, no IP addresses, no names. The endpoint has nowhere to put them and we do not want them: the moment we hold data about your users, you and we both have a harder privacy story for no benefit. event_id is an idempotency key, not a person, and it must not be derived from one.
A signup you report is yours. It is shown to you on your own log and nowhere else. It never affects your rank, your score, your votes, or your dofollow link, and it is never shown to anyone else. Self-reported numbers cannot buy position here, which is the same rule that governs everything else you can pay us for.
Responses
| Code | Meaning | What to do |
|---|---|---|
| 200 | recorded: true counted it, recorded: false means a duplicate event_id | Nothing. Both are success |
| 400 | The body was not JSON, or a field is wrong | Fix the request. Retrying will not help |
| 401 | The key is missing, malformed or revoked | Check the header, or issue a new key |
| 402 | The listing is not on a pro listing | Nothing is stored while this is the answer |
| 404 | No entry on your log carries that reference | Check the ia_ref you captured |
| 429 | Too many events this hour | Back off and retry later. Nothing is lost if you keep the event_id |
| 500 | Our fault | Retry with the same event_id |
Attribution comes with a pro listing. The rest of the expedition log, the record and the liveness checks and the link-back audit, stays free for everyone. See pricing, or read what the log does.