Webhooks
We call your server on these events.
The events
| Event | When |
|---|---|
| outcome.verified | The evidence for that outcome is verified. |
| outcome.paid | The reward went to the person: paid to their address, or credited to them to collect. On a trial, simulated is true: nothing moves. |
| outcome.rejected | The outcome does not count. No note travels with it. |
| outcome.revoked | A verified outcome was revoked, with the cause. |
| notice.test | The delivery your dashboard sends to check the wiring. |
| invitation.joined | Somebody 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"
}
}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.
| Event | What data carries, by type |
|---|---|
| outcome.verified | external_id, campaign_id, status, occurred_at, verified_at, simulated |
| outcome.paid | external_id, campaign_id, status, occurred_at, verified_at, paid_at, reward, certificate, simulated |
| outcome.rejected | external_id, campaign_id, status, occurred_at, rejected_at, simulated |
| outcome.revoked | external_id, campaign_id, status, occurred_at, revoked_at, cause, paid, payment_stands, simulated |
| notice.test | campaign_id, status, simulated |
| invitation.joined | external_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:
cloudflared tunnel --url http://127.0.0.1:8787
Saved, it receives every webhook, real campaigns' too.
The headers
| Header | What it is |
|---|---|
| Campaign-Signature | t=<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-Id | Your dedupe key. Delivery is at least once, so keep it and act once. |
| Campaign-Event-Type | The 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.
| Delivery | What we do |
|---|---|
| Attempts | 6 over a day: at once, +1 min, +5 min, +30 min, +3 h, +20 h |
| Accepted | Any 2xx. A redirect is not followed and counts as a failure |
| Timeout | 10 seconds an attempt |
| Ceiling | 60 webhooks a minute per organization, delayed rather than dropped |
| Timestamp | Refuse 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.