# Outcomes

Report an outcome and read it back: fields, answers, reasons and errors.

Report an outcome from your server, and read it back.

## Report an outcome

`POST https://campaign.udao.org/api/v1/outcomes`

Every call carries the header `Authorization: Bearer camp_sk_live_...`, your API key, and nothing else.

Answers 202 for any well-formed request the API key may make. A fact about the data comes back as a reason, never as an error.

**curl**

```bash
curl https://campaign.udao.org/api/v1/outcomes \
  -H "Authorization: Bearer camp_sk_live_..." \
  -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())
```

### The answer

**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"
}
```

A write the campaign will not take as evidence answers 202 as well, with the reason and the fact behind it:

**202 Accepted**

```json
{
  "external_id": "your-own-id-4c71",
  "campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
  "status": "recorded",
  "reason": "outside_window",
  "received_at": "2026-09-11T13:12:06Z",
  "outcome_url": "https://campaign.udao.org/api/v1/outcomes/your-own-id-4c71?campaign_id=8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
  "details": {
    "grace_ends_at": "2026-09-08T23:59:59Z"
  }
}
```

### The body

| Field | Type | Required | What it is |
| --- | --- | --- | --- |
| `campaign_id` | string | Required | The campaign the outcome belongs to. A campaign of another organization answers 404. At most 64 characters. |
| `email` | string | Required, or `email_hash` | Any address the person has verified on Campaign. Hashed on arrival and never stored. |
| `email_hash` | string | Required, or `email` | The same address, folded, as HMAC-SHA256 under the Email salt shown beside your API key, hex encoded. Send email or email_hash, never both. At most 64 characters. |
| `reference` | string | Required | What they did, in your own words. The same word the file you import uses. At most 200 characters. |
| `occurred_at` | string | Required | RFC 3339 with a zone. The only input the window rules read. At most 64 characters. |
| `external_id` | string | Required | Your own id for this outcome. The dedupe key: a second send of it stores nothing and answers the first reply again. At most 200 characters. |
| `kind` | string | Optional | completed, the default, or revoked to revoke one. A revocation names the external_id of the outcome it revokes and answers 202 with a reason. |

Idempotency-Key is accepted as a header and must equal external_id where both are sent. They disagree, 422.

### The reasons

| Reason | Status | What it means | What to do |
| --- | --- | --- | --- |
| `awaiting_review` | `pending` | Recorded and waiting for a verifier. | Poll the read, or wait for the outcome.verified webhook. |
| `participant_not_joined` | `recorded` | Nobody holding a place has verified that address. | Send them the join URL in details. This id is spent: sent again it answers the first reply again. It is matched once someone who has verified that address holds a place, inside the campaign’s window. |
| `completed_before_join` | `recorded` | The outcome is dated before the place was taken, so the campaign did not cause it. | Nothing. It stays a record either way. Report the next one. |
| `outside_window` | `recorded` | The campaign stopped accepting evidence. | Read grace_ends_at in details. The outcome is recorded rather than refused, because a late outcome can still be attested by hand. |
| `duplicate` | the status the first write answered | That external_id was written before. The event is stored once, and this is the first answer again, whatever has changed since. | Nothing. A second act takes a second id. |
| `decided_by_reviewer` | the place’s own state | A person already decided the outcome for this place. | Nothing. A write never overrides a person. |
| `reference_not_counted` | `recorded` | This campaign names the references it counts and reference was not one of them. The write is kept as a record and no evidence is written. | Check the reference against the ones the campaign was set up with. A campaign that names none counts every reference. |
| `auto_verified` | `verified` | The campaign has auto-verify on, so the outcome verified on its own. Whoever turned it on is named on the record. | Nothing. The read answers verified, and the reward is committed to that place under the campaign’s own caps. |
| `withdrew_pending` | `rejected` | kind revoked, on an outcome nobody had decided yet. It will not be verified. | Nothing. Report the next outcome under a new external_id. |
| `revoked_for_cause` | `revoked` | kind revoked, from an API key allowed to revoke, on a verified outcome. It is verified no longer. | Nothing. The read answers revoked from here on. |
| `payment_stands` | `revoked` | kind revoked, from an API key allowed to revoke, on a paid outcome. Nothing is clawed back and the certificate takes its revoked state. | Nothing. The webhook and the read both say paid and payment_stands, so your own product can say the same. |
| `unknown_outcome` | `recorded` | kind revoked, naming an external_id this campaign never received. | Check the id. It is recorded either way, and a landed revocation gets this same answer. |

Your own dashboard prints the same write on its Integrations page, with a badge for its verdict. The badges read recorded as Not counted, pending as Pending, verified as Verified, revoked as Revoked, refused as Refused, failed as Failed. A request we answered 4xx and a verifier’s refusal are both refused, and the response code that says which is behind the row’s Details.

### The errors

Every error comes back in one body. This is the endpoint’s answer to an occurred_at with no zone:

**422 Unprocessable Content**

```json
{
  "error": {
    "code": "invalid_request",
    "message": "occurred_at is an RFC 3339 instant carrying its zone, such as 2026-09-11T14:03:22Z",
    "details": {
      "field": "occurred_at"
    }
  }
}
```

Branch on `code`. `message` is a sentence for a person and may change. `details` carries the facts its `code` names.

| Status | Code | What it means |
| --- | --- | --- |
| 401 | `not_authenticated` | No API key, or one that was revoked. |
| 403 | `power_not_granted` | The API key tried to revoke a verified or paid outcome on a real campaign. An owner allows that in Integrations. |
| 404 | `campaign_not_found` | No campaign of yours has that id. A campaign of another organization answers the same way, and so does one outside this API key’s scope. |
| 404 | `outcome_not_found` | The read: nothing was reported to that campaign under that id. |
| 409 | `campaign_not_importable` | That campaign is not at a point where evidence is accepted. |
| 409 | `trial_only_credential` | An API key for trial campaigns only named a campaign that is not a trial. Change the API key, not the campaign. |
| 413 | `payload_too_large` | The body is past the bound. A write runs to at most 100 KB, and details.max_bytes carries the figure. |
| 415 | `invalid_request` | The body was not sent as application/json. The endpoint reads one content type and names it. |
| 422 | `invalid_request` | A field is missing, unreadable or not one this endpoint takes. The answer names it. |
| 429 | `rate_limited` | Past the quota. retry_after says when, in seconds. |
| 500 | `server_error` | Our fault. Send the same request again: one external_id never counts twice. |

## Read an outcome

`GET https://campaign.udao.org/api/v1/outcomes/{external_id}?campaign_id={campaign_id}`

The state, the reward in human units beside base units, and the certificate where the holder’s passport shows it.

**curl**

```bash
curl -G \
  -H "Authorization: Bearer camp_sk_live_..." \
  "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())
```

**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": false
  },
  "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"
  }
}
```

| Status | What it means |
| --- | --- |
| `recorded` | The outcome is kept and nothing has been decided about it. |
| `pending` | Evidence is written and a verifier has it. |
| `verified` | A verifier approved it. The reward is committed to that place. |
| `rejected` | The outcome does not count. A verifier decided so. |
| `revoked` | A verified outcome was revoked. |

| Field | What it is |
| --- | --- |
| `status` | The place’s own state, not the record this report made. |
| `reward.simulated` | True on a trial campaign. Read it before you print a figure. |
| `certificate.reference` | The certificate’s id. |
| `certificate.url` | Null with a reason where the holder keeps their passport unlisted. |

## Rate limits

| What | The bound |
| --- | --- |
| Writes | 120 a minute, per organization |
| Reads | 600 a minute, per organization |
| Per IP address | Past 600 requests a minute from one IP address, 429 before your API key is read |
| Past the quota | 429 with retry_after, in seconds, also in the Retry-After header |
