Invitations
Put one person on a campaign’s invitation list, read the invitation back, and hear when they join.
Invite one person
POSThttps://Every call carries the header Authorization: Bearer camp_sk_live_..., your API key, and nothing else.
Answers 202 with the invitation as it stands and a reason, for any request the API key may make. An address already on the list is a reason, never an error.
curl https://campaign.udao.org/api/v1/invitations \
-H "Authorization: Bearer camp_sk_trial_..." \
-H "Content-Type: application/json" \
-d '{
"campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
"email": "ada@example.com",
"external_id": "your-invitation-id-5e21",
"send": true
}'The answer
{
"external_id": "your-invitation-id-5e21",
"campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
"status": "invited",
"reason": "added",
"sent": true,
"blocked": null,
"received_at": "2026-09-11T09:12:04Z",
"invitation_url": "https://campaign.udao.org/api/v1/invitations/your-invitation-id-5e21?campaign_id=8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
"join_url": "https://campaign.udao.org/campaigns/8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74/join?via=partner"
}An address already on the list under another id of yours answers 202 too, and names that id:
{
"external_id": "your-invitation-id-5e21",
"campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
"status": "invited",
"reason": "already_listed",
"sent": false,
"blocked": null,
"received_at": "2026-09-11T09:14:31Z",
"invitation_url": "https://campaign.udao.org/api/v1/invitations/your-invitation-id-1c07?campaign_id=8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
"join_url": "https://campaign.udao.org/campaigns/8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74/join?via=partner",
"details": {
"external_id": "your-invitation-id-1c07"
}
}What the answer carries
| Field | What it is |
|---|---|
| status | invited, joined or removed: where the invitation stands now. |
| sent | True when this call sent the invitation message. |
| blocked | Why a message you asked for did not go, as a code below, or null. |
| invitation_url | The read of this invitation, with campaign_id in place: under details.external_id where the answer carries one, else under your own id. |
| join_url | The campaign’s join link with your app’s mark, for a message of your own. |
| details.external_id | On already_listed only: the id the address is already listed under. |
The body
| Field | Type | Required | What it is |
|---|---|---|---|
| campaign_id | string | Required | The campaign whose list the person goes on. A campaign of another organization answers 404. At most 64 characters. |
| string | Required | The person’s address, as your dashboard’s Invite people takes it. It is kept on the list until they join or are taken off it, and never sent back. Never a hash: we mail the address when send is true, and the join checks it against the person’s own verified address. At most 254 characters. | |
| external_id | string | Required | Your own id for this invitation, unique on the campaign. Sent again with the same address it answers duplicate; the read and the webhook name it. Never the person’s address, which we drop when they join: an id that is an address, or carries this one, answers 422. At most 200 characters. |
| send | boolean | Required | true sends the campaign’s invitation message, the one your dashboard sends; false only puts the address on the list. No default: a message cannot be taken back. |
Idempotency-Key is accepted as a header and must equal external_id where both are sent. They disagree, 422.
The reasons
| Reason | What it means | What to do |
|---|---|---|
| added | The address is on the list now. sent says whether the message went, and blocked names why not where you asked for it. | Nothing. Read the invitation, or wait for invitation.joined. |
| already_listed | The address was on the list already. If it had no id, it takes yours; if it had another, details.external_id names it. Nothing was sent. | Read it at invitation_url, and expect invitation.joined under details.external_id where the answer carries one, else under your own id. Where details.external_id is there, the id you sent names nothing. |
| duplicate | This id was sent before with this address. The answer is the invitation as it stands, and nothing was written or sent. A retry sent while the first call is still running can be answered before that call settles. | Nothing. A new invitation takes a new id. Where it matters, read the invitation: the read is the truth. |
| removed_from_list | The address was taken off the list, by your organization or by the person from the message. The API never adds it back. | Nothing. An owner can add it again in your dashboard. |
Messages not sent
An API invitation is your dashboard’s own: the same list, message, limits and unsubscribe as Invite people, and your owners see the row there. The address stays on the list either way. An owner can send the link again from your dashboard.
| blocked | What it means |
|---|---|
| send_not_available | The campaign is not live: not open yet (a draft, or waiting for its funding) or past its end. Or Udao has suspended your organization. |
| domain_proof_required | A funded campaign sends once your organization has proved its domain. |
| not_configured | This deployment could not send mail. |
| send_refused | The mail provider refused the message. |
| send_cap_reached | Your organization has sent the 1,000 invitations it sends in a day. |
| send_count_unreadable | Today’s count of invitations could not be read, so nothing was sent. |
| recipient_budget_reached | That mailbox has had as much mail from Campaign as it takes in a short time. |
| resend_too_soon | That address had a message inside the last day. |
| resend_exhausted | That address has had the 3 messages one invitation sends. |
| invitation_bounced | That address rejected an earlier message for good. |
The errors
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 | browser_request | The request carried a browser Origin header. Call this from your server: an API key in a page is an API key you have published. |
| 403 | power_not_granted | The API key tried to send an invitation 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. |
| 409 | trial_only_credential | An API key for trial campaigns only named a campaign that is not a trial. |
| 409 | campaign_not_open | The campaign is settled, closed or canceled, so nobody else can be invited to it. |
| 409 | invitation_cap_reached | The list is full. details.scope is campaign for the campaign’s own cap, or organization for the 20 an organization invites before Udao approves it. |
| 413 | payload_too_large | The body is past the bound of 100 KB. |
| 415 | invalid_request | The body was not sent as application/json. |
| 422 | invalid_request | A field is missing, unreadable or not one this call takes, and the answer names it. An external_id already naming another address on the campaign is one of them, and so is an external_id that is an address. |
| 429 | rate_limited | Past the quota: the same 120 writes a minute as the outcomes write. retry_after says when. |
| 500 | server_error | Our fault. Send the same request again: one external_id never adds twice. |
| 500 | not_configured | This deployment lacks a setting it needs to keep a list. Nothing was recorded. |
Read an invitation
GEThttps://The invitation as it stands, by your own id, with the campaign named.
curl -G \ -H "Authorization: Bearer camp_sk_trial_..." \ "https://campaign.udao.org/api/v1/invitations/your-invitation-id-5e21" \ -d campaign_id=8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74
{
"external_id": "your-invitation-id-5e21",
"campaign_id": "8f2c6a1e-4b7d-4e39-a5c0-3d9e1f6b2a74",
"status": "joined",
"invited_at": "2026-09-11T09:12:04Z",
"sends": 1,
"last_sent_at": "2026-09-11T09:12:05Z",
"joined_at": "2026-09-11T13:58:40Z",
"simulated": true
}The states
| Status | What it means |
|---|---|
| invited | On the list. Nobody holding the address has taken a place yet. |
| joined | Somebody holding the invited address took a place, however they joined: from your invitation, the join link or the campaign’s page. joined_at on the read says when. |
| removed | Taken off the list, by your organization or by the person. |
The other fields
| Field | What it is |
|---|---|
| invited_at | When the address went on the list. |
| sends | How many invitation messages went to the address, at most 3. |
| last_sent_at | When the last message went, or null. |
| joined_at | When the person took their place, or null before that. |
| simulated | True on a trial campaign. |
The read’s errors
| Status | Code | What it means |
|---|---|---|
| 401 | not_authenticated | No API key, or one that was revoked. |
| 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 | invitation_not_found | No invitation of that campaign has that id. An invitation added in your dashboard with no id answers the same way, and so does an id another organization sent. |
| 409 | trial_only_credential | An API key for trial campaigns only named a campaign that is not a trial. |
| 422 | invalid_request | campaign_id is missing. The read names the campaign the invitation belongs to. |
| 429 | rate_limited | Past the quota: 600 reads a minute per organization, shared with every read your API keys make. retry_after says when. |
| 500 | server_error | Our fault. Send the same request again: a read changes nothing. |
| 500 | not_configured | This deployment lacks a setting it needs to answer. Nothing was recorded. |
When they join
The person signs in on Campaign and joins themselves. invitation.joined is sent once somebody holding the address takes a place, if your webhook URL is set and verified. Its body carries your external_id, the campaign_id, status joined, joined_at and simulated. The read is the truth.