Try it

Menu

Webhooks

We call your server on these events.

The events

EventWhen
outcome.verifiedThe evidence for that outcome is verified.
outcome.paidThe reward went to the person: paid to their address, or credited to them to collect. On a trial, simulated is true: nothing moves.
outcome.rejectedThe outcome does not count. No note travels with it.
outcome.revokedA verified outcome was revoked, with the cause.
notice.testThe delivery your dashboard sends to check the wiring.
invitation.joinedSomebody holding an address your app invited took a place. Its external_id is the invitation’s.

What we send

POST https://your-server.example/campaign/webhooks
Campaign-Event-Type: outcome.verified
Campaign-Event-Id: 5d8a2f1c-4a90-4c3e-9f21-6b0d7e4a1c55
Campaign-Signature: t=1757599562,v1=<hex>
Content-Type: application/json

{
  "id": "5d8a2f1c-4a90-4c3e-9f21-6b0d7e4a1c55",
  "type": "outcome.verified",
  "created_at": "2026-09-11T14:06:05.000Z",
  "data": {
    "external_id": "your-own-id-8f3a",
    "campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
    "status": "verified",
    "occurred_at": "2026-09-11T14:03:22Z",
    "simulated": false,
    "verified_at": "2026-09-11T14:06:02Z"
  }
}
invitation.joined
POST https://your-server.example/campaign/webhooks
Campaign-Event-Type: invitation.joined
Campaign-Event-Id: 5d8a2f1c-4a90-4c3e-9f21-6b0d7e4a1c55
Campaign-Signature: t=1757599562,v1=<hex>
Content-Type: application/json

{
  "id": "5d8a2f1c-4a90-4c3e-9f21-6b0d7e4a1c55",
  "type": "invitation.joined",
  "created_at": "2026-09-11T14:06:05.000Z",
  "data": {
    "external_id": "your-invitation-id-5e21",
    "campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
    "status": "joined",
    "joined_at": "2026-09-11T13:58:40Z",
    "simulated": true
  }
}

One envelope per webhook: id, type, created_at and data. The body is built at send time from the records, so what a webhook says is what the read would say a moment later. The read is the truth. A webhook is a nudge that saves you polling for it.

EventWhat data carries, by type
outcome.verifiedexternal_id, campaign_id, status, occurred_at, verified_at, simulated
outcome.paidexternal_id, campaign_id, status, occurred_at, verified_at, paid_at, reward, certificate, simulated
outcome.rejectedexternal_id, campaign_id, status, occurred_at, rejected_at, simulated
outcome.revokedexternal_id, campaign_id, status, occurred_at, revoked_at, cause, paid, payment_stands, simulated
notice.testcampaign_id, status, simulated
invitation.joinedexternal_id, campaign_id, status, joined_at, simulated

What a webhook never carries

  • an address, or any hash of one
  • a name or a handle
  • a wallet address, a transaction hash or a content hash
  • a verifier’s note or identity
  • an internal id of ours, or our own error text

Setting one up

An owner or a developer saves it in Integrations, signed in within the last 10 minutes. Webhooks start once your endpoint answers our signed test with a 2xx; one signed wrong gets a 4xx.

What the URL has to be

  • https on port 443, no user or password.
  • A host name, never an IP address and never ours.
  • Public, resolved at save and on every call: loopback, private, link local, carrier grade NAT, unique local, multicast and unspecified addresses refused by name.
  • Answering inside 10 seconds, without a redirect; we read and discard at most 8 kilobytes.

A tunnel gives your machine one:

In your terminal
cloudflared tunnel --url http://127.0.0.1:8787

Saved, it receives every webhook, real campaigns' too.

The headers

HeaderWhat it is
Campaign-Signaturet=<unix seconds>,v1=<hex>, and a second v1= for 24 hours after a rotation. HMAC-SHA256 over the timestamp, a dot and the raw body, under your signing secret.
Campaign-Event-IdYour dedupe key. Delivery is at least once, so keep it and act once.
Campaign-Event-TypeThe same value as type in the body.

Checking the signature

HMAC-SHA256 over the timestamp, a dot, and the raw body, under the signing secret your dashboard reveals. Compare in constant time, and refuse a timestamp more than 5 minutes from your own clock.

import { createHmac, timingSafeEqual } from "node:crypto";

export function isOurs(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((one) => one.trim().split("=")),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const ours = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return header
    .split(",")
    .filter((one) => one.trim().startsWith("v1="))
    .some((one) => {
      const sent = Buffer.from(one.trim().slice(3), "hex");
      return sent.length === ours.length && timingSafeEqual(sent, ours);
    });
}

The body has to be the bytes we sent, before any framework of yours reads them as JSON. A re-serialized body is the one signature mismatch everybody hits.

A rotation bumps a generation and both secrets sign every webhook for 24 hours, in one header. Loop over the v1= values and accept the first that matches, then deploy the new secret when you like.

Delivery

6 attempts over a day, then we stop and your dashboard says so.

DeliveryWhat we do
Attempts6 over a day: at once, +1 min, +5 min, +30 min, +3 h, +20 h
AcceptedAny 2xx. A redirect is not followed and counts as a failure
Timeout10 seconds an attempt
Ceiling60 webhooks a minute per organization, delayed rather than dropped
TimestampRefuse a t more than 5 minutes from your own clock

Revoking an outcome

kind revoked on the write revokes an outcome you reported, named by its external_id, and answers 202 with a reason. A pending outcome is superseded rejected. From an API key allowed to revoke, a verified one is revoked for cause. One already paid is revoked the same way, with nothing clawed back: the webhook says paid and payment_stands at once.

Auto-verify

A developer turns on auto-verify for a trial campaign in Integrations, an owner on the campaign’s Participants. Then outcomes for people outside your team verify on their own. A teammate’s outcome still waits for another member. Turning it on records who and when; one press turns it off.

It is for trial campaigns today, and real ones decide each outcome by hand. When it reaches real campaigns, only an API key that verifies on its own skips the verifier there.