Outcomes
Report an outcome from your server, and read it back.
Report an outcome
POSThttps://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 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"
}'The answer
{
"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:
{
"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. |
| 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:
{
"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
GEThttps://The state, the reward in human units beside base units, and the certificate where the holder’s passport shows it.
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
{
"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 |