# Quickstart

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

- [OpenAPI 3.1](https://campaign.udao.org/developers/openapi.json)
- [Postman collection](https://campaign.udao.org/developers/campaign.postman_collection.json)
- [Example in Node](https://campaign.udao.org/developers/quickstart#the-example)

## 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.

[Open your dashboard](https://campaign.udao.org/console/campaigns/new)

## 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.

```http
Authorization: Bearer camp_sk_trial_...
```

## Step 3. Add the join link

People join through this link alone, which records the terms version they accept.

```text
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.

## 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**

```bash
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"
    }'
```

**Node**

```js
const answer = await fetch("https://campaign.udao.org/api/v1/outcomes", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CAMPAIGN_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "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"
  }),
});
console.log(answer.status, await answer.json());
```

**Python**

```python
import os
import requests

answer = requests.post(
    "https://campaign.udao.org/api/v1/outcomes",
    headers={"Authorization": "Bearer " + os.environ["CAMPAIGN_API_KEY"]},
    json={
        "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"
    },
    timeout=10,
)
print(answer.status_code, answer.json())
```

**202 Accepted**

```json
{
  "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**

```json
{
  "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.

## Step 5. Read the outcome back

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

**curl**

```bash
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
```

**Node**

```js
const url = new URL("https://campaign.udao.org/api/v1/outcomes/your-own-id-8f3a");
url.searchParams.set("campaign_id", "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74");

const answer = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.CAMPAIGN_API_KEY}` },
});
console.log(answer.status, await answer.json());
```

**Python**

```python
import os
import requests

answer = requests.get(
    "https://campaign.udao.org/api/v1/outcomes/your-own-id-8f3a",
    params={"campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74"},
    headers={"Authorization": "Bearer " + os.environ["CAMPAIGN_API_KEY"]},
    timeout=10,
)
print(answer.status_code, answer.json())
```

## 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.

[Open the queue](https://campaign.udao.org/console/campaigns)

[What auto-verify is](https://campaign.udao.org/developers/webhooks#auto-verify)

## 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**

```json
{
  "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**

```bash
CAMPAIGN_API_KEY=camp_sk_trial_... CAMPAIGN_ID=... node campaign-connect.mjs
```

[Download campaign-connect.mjs](https://campaign.udao.org/developers/campaign-connect.mjs)

**campaign-connect.mjs**

```js
// 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.

[Read the reference](https://campaign.udao.org/developers/outcomes)
