Try it

Menu

Quickstart

From a trial campaign to a verified outcome in 7 steps, with simulated rewards.

  1. Step 1. Create a trial campaign

    In your dashboard, mark a campaign a trial at its Reward step.

    While your organization is in review, this trial is open only to the people you invite. It stays unlisted and holds 20 people.

  2. Step 2. Create an API key

    An owner or a developer creates it in Integrations, for trial campaigns. Shown once; keep it on your server.

    Authorization: Bearer camp_sk_trial_...
  3. People join through this link alone, which records the terms version they accept.

    https://campaign.udao.org/campaigns/8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74/join?via=partner&email={email}&return_to={return_url}

    Its via mark tells you which arrivals were yours. Fill email with the address you know for the person, and return_to with a page of yours, each URL-encoded. A return_to off your Allowed websites list is dropped.

  4. Step 4. Report an outcome

    Your own id goes in external_id, and it is the dedupe key: the same id twice answers the first reply again. A 401 means the API key is wrong, and nothing was recorded.

    curl https://campaign.udao.org/api/v1/outcomes \
      -H "Authorization: Bearer camp_sk_trial_..." \
      -H "Content-Type: application/json" \
      -d '{
          "campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
          "email": "ada@example.com",
          "reference": "course-solidity-101",
          "occurred_at": "2026-09-11T14:03:22Z",
          "external_id": "your-own-id-8f3a",
          "kind": "completed"
        }'
    202 Accepted
    {
      "external_id": "your-own-id-8f3a",
      "campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
      "status": "pending",
      "reason": "awaiting_review",
      "received_at": "2026-09-11T14:03:25Z",
      "outcome_url": "https://campaign.udao.org/api/v1/outcomes/your-own-id-8f3a?campaign_id=8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74"
    }
    202 Accepted
    {
      "external_id": "your-own-id-1d92",
      "campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
      "status": "recorded",
      "reason": "participant_not_joined",
      "received_at": "2026-09-11T13:47:11Z",
      "outcome_url": "https://campaign.udao.org/api/v1/outcomes/your-own-id-1d92?campaign_id=8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
      "details": {
        "join_url": "https://campaign.udao.org/campaigns/8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74/join?via=partner"
      }
    }

    For a person who has not taken a place yet, the answer is still 202 and the reason names the fix.

  5. Step 5. Read the outcome back

    One read, by your own id, with the campaign named.

    curl -G \
      -H "Authorization: Bearer camp_sk_trial_..." \
      "https://campaign.udao.org/api/v1/outcomes/your-own-id-8f3a" \
      -d campaign_id=8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74
  6. Step 6. Approve it, or auto-verify

    A server’s write produces evidence a verifier approves in the queue. Approving commits the reward, so it needs a sign-in from the last 10 minutes. Or turn on auto-verify: a developer for a trial in Integrations, an owner on the campaign’s Participants. A teammate’s outcome still waits.

    Today auto-verify is for trial campaigns, and real ones decide each outcome by hand. When it reaches real campaigns, only an API key that verifies on its own skips review there.

  7. Step 7. See the simulated reward

    The read now carries the reward and the certificate. On a trial, reward.simulated is true: read it before your interface prints a figure.

    200 OK
    {
      "external_id": "your-own-id-8f3a",
      "campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
      "status": "verified",
      "verified_at": "2026-09-11T14:06:02Z",
      "reward": {
        "base_units": "25000000000000000000",
        "amount": "25.00",
        "token_symbol": "UDAO",
        "decimals": 18,
        "simulated": true
      },
      "paid_at": "2026-09-11T14:31:40Z",
      "certificate": {
        "reference": "3b7e9d24-6c1a-4f85-b2d0-9e4a7c1f5b38",
        "url": "https://campaign.udao.org/p/ada/3b7e9d24-6c1a-4f85-b2d0-9e4a7c1f5b38"
      }
    }

Run it whole

One file for Node 22 with nothing to install. With an API key for trial campaigns only, it writes one outcome, reads it back and prints both. It refuses any other API key before it sends anything. Run it with receive to check webhooks.

In your terminal
CAMPAIGN_API_KEY=camp_sk_trial_... CAMPAIGN_ID=... node campaign-connect.mjs
campaign-connect.mjs
// campaign-connect.mjs: write one outcome to a trial campaign, read it back, and
// check Campaign's webhooks. Node 22, nothing to install.
//
//   CAMPAIGN_API_KEY=camp_sk_trial_... CAMPAIGN_ID=... node campaign-connect.mjs
//   CAMPAIGN_WEBHOOK_SECRET=... node campaign-connect.mjs receive
//
// Optional: CAMPAIGN_EMAIL (the person, ada@example.com by default),
// CAMPAIGN_REFERENCE, CAMPAIGN_BASE_URL, PORT (8787) and HOST (127.0.0.1).
//
// Campaign delivers webhooks only to a public https address on port 443. On
// your machine, save the https address this prints; saved, it receives every
// webhook, real campaigns' too:
//   cloudflared tunnel --url http://127.0.0.1:8787
// In your server, pass isFromCampaign the raw body, before any parse.
import { Buffer } from "node:buffer";
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { realpathSync } from "node:fs";
import { createServer } from "node:http";
import { argv, env, exit } from "node:process";
import { pathToFileURL } from "node:url";

const BASE = (env.CAMPAIGN_BASE_URL || "https://campaign.udao.org").replace(/\/+$/, "");
const TOLERANCE_SECONDS = 300;
const MAX_BODY_BYTES = 65536;
const KEEP_SECONDS = 94116; // Every retry's delay with its jitter and our minute's lag, plus the window.

// True when the header signs these exact bytes under your secret, inside the window.
export function isFromCampaign(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
  if (typeof header !== "string" || !secret) return false;
  const parts = header.split(",").map((part) => part.trim());
  const t = (parts.find((part) => part.startsWith("t=")) || "").slice(2);
  if (!/^[0-9]{1,15}$/.test(t) || Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false;
  const ours = createHmac("sha256", secret).update(t + ".").update(rawBody).digest();
  return parts.filter((part) => part.startsWith("v1=")).some((part) => {
    const sent = Buffer.from(part.slice(3), "hex");
    return sent.length === ours.length && timingSafeEqual(sent, ours);
  });
}

async function call(method, url, key, body) {
  const response = await fetch(url, {
    method,
    headers: { authorization: "Bearer " + key, ...(body ? { "content-type": "application/json" } : {}) },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(10000),
  });
  return { status: response.status, answer: await response.json().catch(() => ({})) };
}

function stop(line) {
  console.error(line);
  exit(1);
}

function refused(step, { status, answer }) {
  const error = answer.error || {};
  stop(step + ": " + status + " " + (error.code || "no code") + ": " + (error.message || "no message"));
}

async function writeAndRead() {
  const key = env.CAMPAIGN_API_KEY || "";
  if (!key.startsWith("camp_sk_trial_")) {
    stop("Set CAMPAIGN_API_KEY to an API key for trial campaigns only (camp_sk_trial_...). This file never writes to a live campaign.");
  }
  if (!env.CAMPAIGN_ID) stop("Set CAMPAIGN_ID to the id of your trial campaign.");
  if (!BASE) stop("Set CAMPAIGN_BASE_URL to Campaign's address.");
  const write = await call("POST", BASE + "/api/v1/outcomes", key, {
    campaign_id: env.CAMPAIGN_ID,
    email: env.CAMPAIGN_EMAIL || "ada@example.com",
    reference: env.CAMPAIGN_REFERENCE || "campaign-connect-example",
    occurred_at: new Date().toISOString(),
    external_id: randomUUID(),
  });
  if (write.status !== 202) refused("write", write);
  console.log("write: " + write.status + " " + write.answer.status + " " + write.answer.reason);
  if (write.answer.details) console.log("details: " + JSON.stringify(write.answer.details));
  // The read goes back to the address that answered the write, and nowhere else.
  if (new URL(write.answer.outcome_url).origin !== new URL(BASE).origin) stop("read: outcome_url names another origin");
  const read = await call("GET", write.answer.outcome_url, key);
  if (read.status !== 200) refused("read", read);
  console.log("read: " + read.status + " " + read.answer.status);
}

const parsed = (raw) => { try { return JSON.parse(raw.toString("utf8")); } catch { return null; } };

function receive() {
  const secret = env.CAMPAIGN_WEBHOOK_SECRET || "";
  if (!secret) stop("Set CAMPAIGN_WEBHOOK_SECRET to the signing secret your dashboard reveals.");
  const done = new Map(); // Event ids acted on, kept while a retry may come. A real server keeps them in its store.
  const server = createServer((request, response) => {
    const chunks = [];
    let size = 0;
    request.on("data", (chunk) => {
      size += chunk.length;
      if (size <= MAX_BODY_BYTES) chunks.push(chunk);
      else if (!response.headersSent) response.writeHead(413, { connection: "close" }).end(() => request.destroy());
    });
    request.on("end", () => {
      if (response.headersSent) return;
      const raw = Buffer.concat(chunks);
      const signed = request.method === "POST" && isFromCampaign(raw, request.headers["campaign-signature"], secret);
      const event = signed ? parsed(raw) : null;
      // The body is signed and the header is not: the two must agree, and the signed id is the dedupe key.
      if (typeof event?.id !== "string" || event.id !== request.headers["campaign-event-id"]) return void response.writeHead(400).end();
      const now = Date.now();
      for (const [seen, at] of done) if (now - at > KEEP_SECONDS * 1000) done.delete(seen); else break;
      if (!done.has(event.id)) {
        done.set(event.id, now);
        console.log("webhook: " + event.type + " " + event.id); // Act on it here, once.
      }
      response.writeHead(204).end();
    });
  });
  server.listen(Number(env.PORT || 8787), env.HOST || "127.0.0.1", () => {
    const { address, port } = server.address();
    console.log("listening on http://" + address + ":" + port);
    if (!["127.0.0.1", "::1"].includes(address)) console.warn("warning: HOST " + address + " is reachable beyond this machine");
  });
}

if (argv[1] && import.meta.url === pathToFileURL(realpathSync(argv[1])).href) {
  if (argv[2] === "receive") receive();
  else await writeAndRead().catch((error) => stop("request failed: " + error.message));
}

Going live

Duplicate the trial campaign, fund the copy, swap the campaign id. An API key for trial campaigns only changes, with its email salt.